├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── contributors.txt ├── phpunit.xml ├── src ├── Connection │ ├── ArrayConnection.php │ └── Connection.php ├── Mutation │ └── Mutation.php ├── Node │ ├── Node.php │ └── Plural.php └── Relay.php └── tests ├── Connection ├── ArrayConnectionTest.php ├── ConnectionTest.php └── SeparateConnectionTest.php ├── Mutation └── MutationTest.php ├── Node ├── NodeTest.php └── PluralTest.php ├── RelayTest.php ├── StarWarsConnectionTest.php ├── StarWarsData.php ├── StarWarsMutationTest.php ├── StarWarsObjectIdentificationTest.php └── StarWarsSchema.php /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /composer.phar 3 | /composer.lock 4 | /vendor/ 5 | /bin/ 6 | /build/ 7 | /.phpunit.result.cache 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.1 5 | - 7.2 6 | - 7.3 7 | - 7.4 8 | - 8.0 9 | - nightly 10 | 11 | install: 12 | - composer install --prefer-dist 13 | 14 | script: 15 | - mkdir -p build/logs 16 | - ./bin/phpunit --coverage-clover build/logs/clover.xml tests/ 17 | 18 | after_script: 19 | - ./bin/coveralls -v 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, Ivo Meißner 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Relay Library for graphql-php 2 | 3 | This is a library to allow the easy creation of Relay-compliant servers using 4 | the [graphql-php](https://github.com/webonyx/graphql-php) reference implementation 5 | of a GraphQL server. 6 | 7 | [![Build Status](https://travis-ci.org/ivome/graphql-relay-php.svg?branch=master)](https://travis-ci.org/ivome/graphql-relay-php) 8 | [![Coverage Status](https://coveralls.io/repos/github/ivome/graphql-relay-php/badge.svg?branch=master)](https://coveralls.io/github/ivome/graphql-relay-php?branch=master) 9 | 10 | *Note: The code is a port of the original [graphql-relay js implementation](https://github.com/graphql/graphql-relay-js) 11 | from Facebook* (With some minor PHP related adjustments and extensions) 12 | 13 | ## Current Status: 14 | 15 | The basic functionality with the helper functions is in place along with the tests. Only the asynchronous functionality 16 | was not yet ported due to the limitations of PHP. 17 | See also discussions [here](https://github.com/ivome/graphql-relay-php/issues/1) and [here](https://github.com/webonyx/graphql-php/issues/42) 18 | 19 | ## Getting Started 20 | 21 | A basic understanding of GraphQL and of the graphql-php implementation is needed 22 | to provide context for this library. 23 | 24 | An overview of GraphQL in general is available in the 25 | [README](https://github.com/facebook/graphql/blob/master/README.md) for the 26 | [Specification for GraphQL](https://github.com/facebook/graphql). 27 | 28 | This library is designed to work with the 29 | [graphql-php](https://github.com/webonyx/graphql-php) reference implementation 30 | of a GraphQL server. 31 | 32 | An overview of the functionality that a Relay-compliant GraphQL server should 33 | provide is in the [GraphQL Relay Specification](https://facebook.github.io/relay/docs/graphql-relay-specification.html) 34 | on the [Relay website](https://facebook.github.io/relay/). That overview 35 | describes a simple set of examples that exist as [tests](tests) in this 36 | repository. A good way to get started with this repository is to walk through 37 | that documentation and the corresponding tests in this library together. 38 | 39 | ## Using Relay Library for graphql-php 40 | 41 | Install this repository via composer: 42 | 43 | ```sh 44 | composer require ivome/graphql-relay-php 45 | ``` 46 | 47 | When building a schema for [graphql-php](https://github.com/webonyx/graphql-php), 48 | the provided library functions can be used to simplify the creation of Relay 49 | patterns. 50 | 51 | ### Connections 52 | 53 | Helper functions are provided for both building the GraphQL types 54 | for connections and for implementing the `resolve` method for fields 55 | returning those types. 56 | 57 | - `Relay::connectionArgs` returns the arguments that fields should provide when they 58 | return a connection type that supports bidirectional pagination. 59 | - `Relay::forwardConnectionArgs` returns the arguments that fields should provide 60 | when they return a connection type that only supports forward pagination. 61 | - `Relay::backwardConnectionArgs` returns the arguments that fields should provide 62 | when they return a connection type that only supports backward pagination. 63 | - `Relay::connectionDefinitions` returns a `connectionType` and its associated 64 | `edgeType`, given a node type. 65 | - `Relay::edgeType` returns a new `edgeType` 66 | - `Relay::connectionType` returns a new `connectionType` 67 | - `Relay::connectionFromArray` is a helper method that takes an array and the 68 | arguments from `connectionArgs`, does pagination and filtering, and returns 69 | an object in the shape expected by a `connectionType`'s `resolve` function. 70 | - `Relay::cursorForObjectInConnection` is a helper method that takes an array and a 71 | member object, and returns a cursor for use in the mutation payload. 72 | 73 | An example usage of these methods from the [test schema](tests/StarWarsSchema.php): 74 | 75 | ```php 76 | $shipConnection = Relay::connectionDefinitions([ 77 | 'nodeType' => $shipType 78 | ]); 79 | 80 | // this could also be written as 81 | // 82 | // $shipEdge = Relay::edgeType([ 83 | // 'nodeType' => $shipType 84 | // ]); 85 | // $shipConnection = Relay::connectionType([ 86 | // 'nodeType' => $shipType, 87 | // 'edgeType' => $shipEdge 88 | // ]); 89 | 90 | $factionType = new ObjectType([ 91 | 'name' => 'Faction', 92 | 'description' => 'A faction in the Star Wars saga', 93 | 'fields' => function() use ($shipConnection) { 94 | return [ 95 | 'id' => Relay::globalIdField(), 96 | 'name' => [ 97 | 'type' => Type::string(), 98 | 'description' => 'The name of the faction.' 99 | ], 100 | 'ships' => [ 101 | 'type' => $shipConnection['connectionType'], 102 | 'description' => 'The ships used by the faction.', 103 | 'args' => Relay::connectionArgs(), 104 | 'resolve' => function($faction, $args) { 105 | // Map IDs from faction back to ships 106 | $data = array_map(function($id) { 107 | return StarWarsData::getShip($id); 108 | }, $faction['ships']); 109 | return Relay::connectionFromArray($data, $args); 110 | } 111 | ] 112 | ]; 113 | }, 114 | 'interfaces' => [$nodeDefinition['nodeInterface']] 115 | ]); 116 | ``` 117 | 118 | This shows adding a `ships` field to the `Faction` object that is a connection. 119 | It uses `connectionDefinitions({nodeType: shipType})` to create the connection 120 | type, adds `connectionArgs` as arguments on this function, and then implements 121 | the resolve function by passing the array of ships and the arguments to 122 | `connectionFromArray`. 123 | 124 | ### Object Identification 125 | 126 | Helper functions are provided for both building the GraphQL types 127 | for nodes and for implementing global IDs around local IDs. 128 | 129 | - `Relay::nodeDefinitions` returns the `Node` interface that objects can implement, 130 | and returns the `node` root field to include on the query type. To implement 131 | this, it takes a function to resolve an ID to an object, and to determine 132 | the type of a given object. 133 | - `Relay::toGlobalId` takes a type name and an ID specific to that type name, 134 | and returns a "global ID" that is unique among all types. 135 | - `Relay::fromGlobalId` takes the "global ID" created by `toGlobalID`, and returns 136 | the type name and ID used to create it. 137 | - `Relay::globalIdField` creates the configuration for an `id` field on a node. 138 | - `Relay::pluralIdentifyingRootField` creates a field that accepts a list of 139 | non-ID identifiers (like a username) and maps then to their corresponding 140 | objects. 141 | 142 | An example usage of these methods from the [test schema](tests/StarWarsSchema.php): 143 | 144 | ```php 145 | $nodeDefinition = Relay::nodeDefinitions( 146 | // The ID fetcher definition 147 | function ($globalId) { 148 | $idComponents = Relay::fromGlobalId($globalId); 149 | if ($idComponents['type'] === 'Faction'){ 150 | return StarWarsData::getFaction($idComponents['id']); 151 | } else if ($idComponents['type'] === 'Ship'){ 152 | return StarWarsData::getShip($idComponents['id']); 153 | } else { 154 | return null; 155 | } 156 | }, 157 | // Type resolver 158 | function ($object) { 159 | return isset($object['ships']) ? self::getFactionType() : self::getShipType(); 160 | } 161 | ); 162 | 163 | $factionType = new ObjectType([ 164 | 'name' => 'Faction', 165 | 'description' => 'A faction in the Star Wars saga', 166 | 'fields' => function() use ($shipConnection) { 167 | return [ 168 | 'id' => Relay::globalIdField(), 169 | 'name' => [ 170 | 'type' => Type::string(), 171 | 'description' => 'The name of the faction.' 172 | ], 173 | 'ships' => [ 174 | 'type' => $shipConnection['connectionType'], 175 | 'description' => 'The ships used by the faction.', 176 | 'args' => Relay::connectionArgs(), 177 | 'resolve' => function($faction, $args) { 178 | // Map IDs from faction back to ships 179 | $data = array_map(function($id) { 180 | return StarWarsData::getShip($id); 181 | }, $faction['ships']); 182 | return Relay::connectionFromArray($data, $args); 183 | } 184 | ] 185 | ]; 186 | }, 187 | 'interfaces' => [$nodeDefinition['nodeInterface']] 188 | ]); 189 | 190 | $queryType = new ObjectType([ 191 | 'name' => 'Query', 192 | 'fields' => function () use ($nodeDefinition) { 193 | return [ 194 | 'node' => $nodeDefinition['nodeField'] 195 | ]; 196 | }, 197 | ]); 198 | ``` 199 | 200 | This uses `Relay::nodeDefinitions` to construct the `Node` interface and the `node` 201 | field; it uses `fromGlobalId` to resolve the IDs passed in in the implementation 202 | of the function mapping ID to object. It then uses the `Relay::globalIdField` method to 203 | create the `id` field on `Faction`, which also ensures implements the 204 | `nodeInterface`. Finally, it adds the `node` field to the query type, using the 205 | `nodeField` returned by `Relay::nodeDefinitions`. 206 | 207 | ### Mutations 208 | 209 | A helper function is provided for building mutations with 210 | single inputs and client mutation IDs. 211 | 212 | - `Relay::mutationWithClientMutationId` takes a name, input fields, output fields, 213 | and a mutation method to map from the input fields to the output fields, 214 | performing the mutation along the way. It then creates and returns a field 215 | configuration that can be used as a top-level field on the mutation type. 216 | 217 | An example usage of these methods from the [test schema](tests/StarWarsSchema.php): 218 | 219 | ```php 220 | $shipMutation = Relay::mutationWithClientMutationId([ 221 | 'name' => 'IntroduceShip', 222 | 'inputFields' => [ 223 | 'shipName' => [ 224 | 'type' => Type::nonNull(Type::string()) 225 | ], 226 | 'factionId' => [ 227 | 'type' => Type::nonNull(Type::id()) 228 | ] 229 | ], 230 | 'outputFields' => [ 231 | 'ship' => [ 232 | 'type' => $shipType, 233 | 'resolve' => function ($payload) { 234 | return StarWarsData::getShip($payload['shipId']); 235 | } 236 | ], 237 | 'faction' => [ 238 | 'type' => $factionType, 239 | 'resolve' => function ($payload) { 240 | return StarWarsData::getFaction($payload['factionId']); 241 | } 242 | ] 243 | ], 244 | 'mutateAndGetPayload' => function ($input) { 245 | $newShip = StarWarsData::createShip($input['shipName'], $input['factionId']); 246 | return [ 247 | 'shipId' => $newShip['id'], 248 | 'factionId' => $input['factionId'] 249 | ]; 250 | } 251 | ]); 252 | 253 | $mutationType = new ObjectType([ 254 | 'name' => 'Mutation', 255 | 'fields' => function () use ($shipMutation) { 256 | return [ 257 | 'introduceShip' => $shipMutation 258 | ]; 259 | } 260 | ]); 261 | ``` 262 | 263 | This code creates a mutation named `IntroduceShip`, which takes a faction 264 | ID and a ship name as input. It outputs the `Faction` and the `Ship` in 265 | question. `mutateAndGetPayload` then gets an object with a property for 266 | each input field, performs the mutation by constructing the new ship, then 267 | returns an object that will be resolved by the output fields. 268 | 269 | Our mutation type then creates the `introduceShip` field using the return 270 | value of `Relay::mutationWithClientMutationId`. 271 | 272 | ## Contributing 273 | 274 | After cloning this repo, ensure dependencies are installed by running: 275 | 276 | ```sh 277 | composer install 278 | ``` 279 | 280 | After developing, the full test suite can be evaluated by running: 281 | 282 | ```sh 283 | bin/phpunit tests 284 | ``` 285 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ivome/graphql-relay-php", 3 | "description": "A PHP port of GraphQL Relay reference implementation", 4 | "type": "library", 5 | "license": "BSD-3-Clause", 6 | "homepage": "https://github.com/ivome/graphql-relay-php", 7 | "keywords": [ 8 | "graphql", 9 | "relay", 10 | "API" 11 | ], 12 | "require": { 13 | "php": "^7.1 || ^8.0", 14 | "webonyx/graphql-php": "^14.0 || ^15.0" 15 | }, 16 | "require-dev": { 17 | "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", 18 | "satooshi/php-coveralls": "~1.0" 19 | }, 20 | "config": { 21 | "bin-dir": "bin" 22 | }, 23 | "autoload": { 24 | "classmap": [ 25 | "src/" 26 | ] 27 | }, 28 | "autoload-dev": { 29 | "classmap": [ 30 | "tests/" 31 | ], 32 | "files": [ 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /contributors.txt: -------------------------------------------------------------------------------- 1 | Ivo Meißner 2 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tests 5 | 6 | 7 | 8 | 9 | src 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Connection/ArrayConnection.php: -------------------------------------------------------------------------------- 1 | 0, 63 | 'arrayLength' => count($data) 64 | ]); 65 | } 66 | 67 | /** 68 | * Given a slice (subset) of an array, returns a connection object for use in 69 | * GraphQL. 70 | * 71 | * This function is similar to `connectionFromArray`, but is intended for use 72 | * cases where you know the cardinality of the connection, consider it too large 73 | * to materialize the entire array, and instead wish pass in a slice of the 74 | * total result large enough to cover the range specified in `args`. 75 | * 76 | * @return array 77 | */ 78 | public static function connectionFromArraySlice(array $arraySlice, $args, $meta) 79 | { 80 | $after = self::getArrayValueSafe($args, 'after'); 81 | $before = self::getArrayValueSafe($args, 'before'); 82 | $first = self::getArrayValueSafe($args, 'first'); 83 | $last = self::getArrayValueSafe($args, 'last'); 84 | $sliceStart = self::getArrayValueSafe($meta, 'sliceStart'); 85 | $arrayLength = self::getArrayValueSafe($meta, 'arrayLength'); 86 | $sliceEnd = $sliceStart + count($arraySlice); 87 | $beforeOffset = self::getOffsetWithDefault($before, $arrayLength); 88 | $afterOffset = self::getOffsetWithDefault($after, -1); 89 | 90 | $startOffset = max([ 91 | $sliceStart - 1, 92 | $afterOffset, 93 | -1 94 | ]) + 1; 95 | 96 | $endOffset = min([ 97 | $sliceEnd, 98 | $beforeOffset, 99 | $arrayLength 100 | ]); 101 | if ($first !== null) { 102 | $endOffset = min([ 103 | $endOffset, 104 | $startOffset + $first 105 | ]); 106 | } 107 | 108 | if ($last !== null) { 109 | $startOffset = max([ 110 | $startOffset, 111 | $endOffset - $last 112 | ]); 113 | } 114 | 115 | $slice = array_slice($arraySlice, 116 | max($startOffset - $sliceStart, 0), 117 | count($arraySlice) - ($sliceEnd - $endOffset) - max($startOffset - $sliceStart, 0) 118 | ); 119 | 120 | $edges = array_map(function($item, $index) use ($startOffset) { 121 | return [ 122 | 'cursor' => self::offsetToCursor($startOffset + $index), 123 | 'node' => $item 124 | ]; 125 | }, $slice, array_keys($slice)); 126 | 127 | $firstEdge = $edges ? $edges[0] : null; 128 | $lastEdge = $edges ? $edges[count($edges) - 1] : null; 129 | $lowerBound = $after ? ($afterOffset + 1) : 0; 130 | $upperBound = $before ? ($beforeOffset) : $arrayLength; 131 | 132 | return [ 133 | 'edges' => $edges, 134 | 'pageInfo' => [ 135 | 'startCursor' => $firstEdge ? $firstEdge['cursor'] : null, 136 | 'endCursor' => $lastEdge ? $lastEdge['cursor'] : null, 137 | 'hasPreviousPage' => $last !== null ? $startOffset > $lowerBound : false, 138 | 'hasNextPage' => $first !== null ? $endOffset < $upperBound : false 139 | ] 140 | ]; 141 | } 142 | 143 | /** 144 | * Return the cursor associated with an object in an array. 145 | * 146 | * @param array $data 147 | * @param $object 148 | * @return null|string 149 | */ 150 | public static function cursorForObjectInConnection(array $data, $object) 151 | { 152 | $offset = -1; 153 | for ($i = 0; $i < count($data); $i++) { 154 | if ($data[$i] == $object){ 155 | $offset = $i; 156 | break; 157 | } 158 | } 159 | 160 | if ($offset === -1) { 161 | return null; 162 | } 163 | 164 | return self::offsetToCursor($offset); 165 | } 166 | 167 | /** 168 | * Returns the value for the given array key, NULL, if it does not exist 169 | * 170 | * @param array $array 171 | * @param string $key 172 | * @return mixed 173 | */ 174 | protected static function getArrayValueSafe(array $array, $key) 175 | { 176 | return array_key_exists($key, $array) ? $array[$key] : null; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Connection/Connection.php: -------------------------------------------------------------------------------- 1 | [ 29 | 'type' => Type::string() 30 | ], 31 | 'first' => [ 32 | 'type' => Type::int() 33 | ] 34 | ]; 35 | } 36 | 37 | /** 38 | * Returns a GraphQLFieldConfigArgumentMap appropriate to include on a field 39 | * whose return type is a connection type with backward pagination. 40 | * 41 | * @return array 42 | */ 43 | public static function backwardConnectionArgs() 44 | { 45 | return [ 46 | 'before' => [ 47 | 'type' => Type::string() 48 | ], 49 | 'last' => [ 50 | 'type' => Type::int() 51 | ] 52 | ]; 53 | } 54 | 55 | /** 56 | * Returns a GraphQLFieldConfigArgumentMap appropriate to include on a field 57 | * whose return type is a connection type with bidirectional pagination. 58 | * 59 | * @return array 60 | */ 61 | public static function connectionArgs() 62 | { 63 | return array_merge( 64 | self::forwardConnectionArgs(), 65 | self::backwardConnectionArgs() 66 | ); 67 | } 68 | 69 | /** 70 | * Returns a GraphQLObjectType for a connection with the given name, 71 | * and whose nodes are of the specified type. 72 | */ 73 | public static function connectionDefinitions(array $config) 74 | { 75 | return [ 76 | 'edgeType' => self::createEdgeType($config), 77 | 'connectionType' => self::createConnectionType($config) 78 | ]; 79 | } 80 | 81 | /** 82 | * Returns a GraphQLObjectType for a connection with the given name, 83 | * and whose nodes are of the specified type. 84 | * 85 | * @return ObjectType 86 | */ 87 | public static function createConnectionType(array $config) 88 | { 89 | if (!array_key_exists('nodeType', $config)){ 90 | throw new \InvalidArgumentException('Connection config needs to have at least a node definition'); 91 | } 92 | $nodeType = $config['nodeType']; 93 | $name = array_key_exists('name', $config) ? $config['name'] : $nodeType->name; 94 | $connectionFields = array_key_exists('connectionFields', $config) ? $config['connectionFields'] : []; 95 | $edgeType = array_key_exists('edgeType', $config) ? $config['edgeType'] : null; 96 | 97 | $connectionType = new ObjectType([ 98 | 'name' => $name . 'Connection', 99 | 'description' => 'A connection to a list of items.', 100 | 'fields' => function() use ($edgeType, $connectionFields, $config) { 101 | return array_merge([ 102 | 'pageInfo' => [ 103 | 'type' => Type::nonNull(self::pageInfoType()), 104 | 'description' => 'Information to aid in pagination.' 105 | ], 106 | 'edges' => [ 107 | 'type' => Type::listOf($edgeType ?: self::createEdgeType($config)), 108 | 'description' => 'Information to aid in pagination' 109 | ] 110 | ], self::resolveMaybeThunk($connectionFields)); 111 | } 112 | ]); 113 | 114 | return $connectionType; 115 | } 116 | 117 | /** 118 | * Returns a GraphQLObjectType for an edge with the given name, 119 | * and whose nodes are of the specified type. 120 | * 121 | * @param array $config 122 | * @return ObjectType 123 | */ 124 | public static function createEdgeType(array $config) 125 | { 126 | if (!array_key_exists('nodeType', $config)){ 127 | throw new \InvalidArgumentException('Edge config needs to have at least a node definition'); 128 | } 129 | $nodeType = $config['nodeType']; 130 | $name = array_key_exists('name', $config) ? $config['name'] : $nodeType->name; 131 | $edgeFields = array_key_exists('edgeFields', $config) ? $config['edgeFields'] : []; 132 | $resolveNode = array_key_exists('resolveNode', $config) ? $config['resolveNode'] : null; 133 | $resolveCursor = array_key_exists('resolveCursor', $config) ? $config['resolveCursor'] : null; 134 | 135 | $edgeType = new ObjectType(array_merge([ 136 | 'name' => $name . 'Edge', 137 | 'description' => 'An edge in a connection', 138 | 'fields' => function() use ($nodeType, $resolveNode, $resolveCursor, $edgeFields) { 139 | return array_merge([ 140 | 'node' => [ 141 | 'type' => $nodeType, 142 | 'resolve' => $resolveNode, 143 | 'description' => 'The item at the end of the edge' 144 | ], 145 | 'cursor' => [ 146 | 'type' => Type::nonNull(Type::string()), 147 | 'resolve' => $resolveCursor, 148 | 'description' => 'A cursor for use in pagination' 149 | ] 150 | ], self::resolveMaybeThunk($edgeFields)); 151 | } 152 | ])); 153 | 154 | return $edgeType; 155 | } 156 | 157 | /** 158 | * The common page info type used by all connections. 159 | * 160 | * @return ObjectType 161 | */ 162 | public static function pageInfoType() 163 | { 164 | if (self::$pageInfoType === null){ 165 | self::$pageInfoType = new ObjectType([ 166 | 'name' => 'PageInfo', 167 | 'description' => 'Information about pagination in a connection.', 168 | 'fields' => [ 169 | 'hasNextPage' => [ 170 | 'type' => Type::nonNull(Type::boolean()), 171 | 'description' => 'When paginating forwards, are there more items?' 172 | ], 173 | 'hasPreviousPage' => [ 174 | 'type' => Type::nonNull(Type::boolean()), 175 | 'description' => 'When paginating backwards, are there more items?' 176 | ], 177 | 'startCursor' => [ 178 | 'type' => Type::string(), 179 | 'description' => 'When paginating backwards, the cursor to continue.' 180 | ], 181 | 'endCursor' => [ 182 | 'type' => Type::string(), 183 | 'description' => 'When paginating forwards, the cursor to continue.' 184 | ] 185 | ] 186 | ]); 187 | } 188 | return self::$pageInfoType; 189 | } 190 | 191 | protected static function resolveMaybeThunk ($thinkOrThunk) 192 | { 193 | return is_callable($thinkOrThunk) ? call_user_func($thinkOrThunk) : $thinkOrThunk; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/Mutation/Mutation.php: -------------------------------------------------------------------------------- 1 | [ 54 | 'type' => Type::string() 55 | ] 56 | ]); 57 | }; 58 | 59 | $augmentedOutputFields = function () use ($outputFields) { 60 | $outputFieldsResolved = self::resolveMaybeThunk($outputFields); 61 | return array_merge($outputFieldsResolved !== null ? $outputFieldsResolved : [], [ 62 | 'clientMutationId' => [ 63 | 'type' => Type::string() 64 | ] 65 | ]); 66 | }; 67 | 68 | $outputType = new ObjectType([ 69 | 'name' => $name . 'Payload', 70 | 'fields' => $augmentedOutputFields 71 | ]); 72 | 73 | $inputType = new InputObjectType([ 74 | 'name' => $name . 'Input', 75 | 'fields' => $augmentedInputFields 76 | ]); 77 | 78 | $definition = [ 79 | 'type' => $outputType, 80 | 'args' => [ 81 | 'input' => [ 82 | 'type' => Type::nonNull($inputType) 83 | ] 84 | ], 85 | 'resolve' => function ($query, $args, $context, ResolveInfo $info) use ($mutateAndGetPayload) { 86 | $payload = call_user_func($mutateAndGetPayload, $args['input'], $context, $info); 87 | $payload['clientMutationId'] = isset($args['input']['clientMutationId']) ? $args['input']['clientMutationId'] : null; 88 | return $payload; 89 | } 90 | ]; 91 | if (array_key_exists('description', $config)){ 92 | $definition['description'] = $config['description']; 93 | } 94 | if (array_key_exists('deprecationReason', $config)){ 95 | $definition['deprecationReason'] = $config['deprecationReason']; 96 | } 97 | return $definition; 98 | } 99 | 100 | /** 101 | * Returns the value for the given array key, NULL, if it does not exist 102 | * 103 | * @param array $array 104 | * @param string $key 105 | * @return mixed 106 | */ 107 | protected static function getArrayValue(array $array, $key) 108 | { 109 | if (array_key_exists($key, $array)){ 110 | return $array[$key]; 111 | } else { 112 | throw new \InvalidArgumentException('The config value for "' . $key . '" is required, but missing in MutationConfig."'); 113 | } 114 | } 115 | 116 | protected static function resolveMaybeThunk($thinkOrThunk) 117 | { 118 | return is_callable($thinkOrThunk) ? call_user_func($thinkOrThunk) : $thinkOrThunk; 119 | } 120 | } -------------------------------------------------------------------------------- /src/Node/Node.php: -------------------------------------------------------------------------------- 1 | 'Node', 34 | 'description' => 'An object with an ID', 35 | 'fields' => [ 36 | 'id' => [ 37 | 'type' => Type::nonNull(Type::id()), 38 | 'description' => 'The id of the object', 39 | ] 40 | ], 41 | 'resolveType' => $typeResolver 42 | ]); 43 | 44 | $nodeField = [ 45 | 'name' => 'node', 46 | 'description' => 'Fetches an object given its ID', 47 | 'type' => $nodeInterface, 48 | 'args' => [ 49 | 'id' => [ 50 | 'type' => Type::nonNull(Type::id()), 51 | 'description' => 'The ID of an object' 52 | ] 53 | ], 54 | 'resolve' => function ($obj, $args, $context, $info) use ($idFetcher) { 55 | return $idFetcher($args['id'], $context, $info); 56 | } 57 | ]; 58 | 59 | return [ 60 | 'nodeInterface' => $nodeInterface, 61 | 'nodeField' => $nodeField 62 | ]; 63 | } 64 | 65 | /** 66 | * Takes a type name and an ID specific to that type name, and returns a 67 | * "global ID" that is unique among all types. 68 | * 69 | * @param string $type 70 | * @param string $id 71 | * @return string 72 | */ 73 | public static function toGlobalId($type, $id) { 74 | return base64_encode($type . GLOBAL_ID_DELIMITER . $id); 75 | } 76 | 77 | /** 78 | * Takes the "global ID" created by self::toGlobalId, and returns the type name and ID 79 | * used to create it. 80 | * 81 | * @param $globalId 82 | * @return array 83 | */ 84 | public static function fromGlobalId($globalId) { 85 | $unbasedGlobalId = base64_decode($globalId); 86 | $delimiterPos = strpos($unbasedGlobalId, GLOBAL_ID_DELIMITER); 87 | return [ 88 | 'type' => substr($unbasedGlobalId, 0, $delimiterPos), 89 | 'id' => substr($unbasedGlobalId, $delimiterPos + 1) 90 | ]; 91 | } 92 | 93 | /** 94 | * Creates the configuration for an id field on a node, using `self::toGlobalId` to 95 | * construct the ID from the provided typename. The type-specific ID is fetched 96 | * by calling idFetcher on the object, or if not provided, by accessing the `id` 97 | * property on the object. 98 | * 99 | * @param string|null $typeName 100 | * @param callable|null $idFetcher 101 | * @return array 102 | */ 103 | public static function globalIdField($typeName = null, callable $idFetcher = null) { 104 | return [ 105 | 'name' => 'id', 106 | 'description' => 'The ID of an object', 107 | 'type' => Type::nonNull(Type::id()), 108 | 'resolve' => function($obj, $args, $context, $info) use ($typeName, $idFetcher) { 109 | return self::toGlobalId( 110 | $typeName !== null ? $typeName : $info->parentType->name, 111 | $idFetcher ? $idFetcher($obj, $info) : $obj['id'] 112 | ); 113 | } 114 | ]; 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /src/Node/Plural.php: -------------------------------------------------------------------------------- 1 | ?any, 23 | * description?: ?string, 24 | * }; 25 | * 26 | * @param array $config 27 | * @return array 28 | */ 29 | public static function pluralIdentifyingRootField(array $config) 30 | { 31 | $inputArgs = []; 32 | $argName = self::getArrayValue($config, 'argName'); 33 | $inputArgs[$argName] = [ 34 | 'type' => Type::nonNull( 35 | Type::listOf( 36 | Type::nonNull(self::getArrayValue($config, 'inputType')) 37 | ) 38 | ) 39 | ]; 40 | 41 | return [ 42 | 'description' => isset($config['description']) ? $config['description'] : null, 43 | 'type' => Type::listOf(self::getArrayValue($config, 'outputType')), 44 | 'args' => $inputArgs, 45 | 'resolve' => function ($obj, $args, $context, ResolveInfo $info) use ($argName, $config) { 46 | $inputs = $args[$argName]; 47 | return array_map(function($input) use ($config, $context, $info) { 48 | return call_user_func(self::getArrayValue($config, 'resolveSingleInput'), $input, $context, $info); 49 | }, $inputs); 50 | } 51 | ]; 52 | } 53 | 54 | /** 55 | * Returns the value for the given array key, NULL, if it does not exist 56 | * 57 | * @param array $array 58 | * @param string $key 59 | * @return mixed 60 | */ 61 | protected static function getArrayValue(array $array, $key) 62 | { 63 | if (array_key_exists($key, $array)){ 64 | return $array[$key]; 65 | } else { 66 | throw new \InvalidArgumentException('The config value for "' . $key . '" is required, but missing in PluralIdentifyingRootFieldConfig."'); 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /src/Relay.php: -------------------------------------------------------------------------------- 1 | letters, []); 20 | 21 | $expected = array ( 22 | 'edges' => 23 | array ( 24 | 0 => 25 | array ( 26 | 'node' => 'A', 27 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 28 | ), 29 | 1 => 30 | array ( 31 | 'node' => 'B', 32 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 33 | ), 34 | 2 => 35 | array ( 36 | 'node' => 'C', 37 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 38 | ), 39 | 3 => 40 | array ( 41 | 'node' => 'D', 42 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 43 | ), 44 | 4 => 45 | array ( 46 | 'node' => 'E', 47 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 48 | ), 49 | ), 50 | 'pageInfo' => 51 | array ( 52 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 53 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 54 | 'hasPreviousPage' => false, 55 | 'hasNextPage' => false, 56 | ), 57 | ); 58 | 59 | $this->assertEquals($expected, $connection); 60 | } 61 | 62 | public function testRespectsASmallerFirst() 63 | { 64 | $connection = ArrayConnection::connectionFromArray($this->letters, ['first' => 2]); 65 | 66 | $expected = array ( 67 | 'edges' => 68 | array ( 69 | 0 => 70 | array ( 71 | 'node' => 'A', 72 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 73 | ), 74 | 1 => 75 | array ( 76 | 'node' => 'B', 77 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 78 | ), 79 | ), 80 | 'pageInfo' => 81 | array ( 82 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 83 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 84 | 'hasPreviousPage' => false, 85 | 'hasNextPage' => true, 86 | ), 87 | ); 88 | 89 | $this->assertEquals($expected, $connection); 90 | } 91 | 92 | public function testRespectsAnOverlyLargeFirst() 93 | { 94 | $connection = ArrayConnection::connectionFromArray($this->letters, ['first' => 10]); 95 | 96 | $expected = array ( 97 | 'edges' => 98 | array ( 99 | 0 => 100 | array ( 101 | 'node' => 'A', 102 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 103 | ), 104 | 1 => 105 | array ( 106 | 'node' => 'B', 107 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 108 | ), 109 | 2 => 110 | array ( 111 | 'node' => 'C', 112 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 113 | ), 114 | 3 => 115 | array ( 116 | 'node' => 'D', 117 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 118 | ), 119 | 4 => 120 | array ( 121 | 'node' => 'E', 122 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 123 | ), 124 | ), 125 | 'pageInfo' => 126 | array ( 127 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 128 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 129 | 'hasPreviousPage' => false, 130 | 'hasNextPage' => false, 131 | ), 132 | ); 133 | 134 | $this->assertEquals($expected, $connection); 135 | } 136 | 137 | public function testRespectsASmallerLast() 138 | { 139 | $connection = ArrayConnection::connectionFromArray($this->letters, ['last' => 2]); 140 | 141 | $expected = array ( 142 | 'edges' => 143 | array ( 144 | 0 => 145 | array ( 146 | 'node' => 'D', 147 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 148 | ), 149 | 1 => 150 | array ( 151 | 'node' => 'E', 152 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 153 | ), 154 | ), 155 | 'pageInfo' => 156 | array ( 157 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 158 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 159 | 'hasPreviousPage' => true, 160 | 'hasNextPage' => false, 161 | ), 162 | ); 163 | 164 | $this->assertEquals($expected, $connection); 165 | } 166 | 167 | public function testRespectsAnOverlyLargeLast() 168 | { 169 | $connection = ArrayConnection::connectionFromArray($this->letters, ['last' => 10]); 170 | 171 | $expected = array ( 172 | 'edges' => 173 | array ( 174 | 0 => 175 | array ( 176 | 'node' => 'A', 177 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 178 | ), 179 | 1 => 180 | array ( 181 | 'node' => 'B', 182 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 183 | ), 184 | 2 => 185 | array ( 186 | 'node' => 'C', 187 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 188 | ), 189 | 3 => 190 | array ( 191 | 'node' => 'D', 192 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 193 | ), 194 | 4 => 195 | array ( 196 | 'node' => 'E', 197 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 198 | ), 199 | ), 200 | 'pageInfo' => 201 | array ( 202 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 203 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 204 | 'hasPreviousPage' => false, 205 | 'hasNextPage' => false, 206 | ), 207 | ); 208 | 209 | $this->assertEquals($expected, $connection); 210 | } 211 | 212 | public function testRespectsFirstAndAfter() 213 | { 214 | $connection = ArrayConnection::connectionFromArray($this->letters, ['first' => 2, 'after' => 'YXJyYXljb25uZWN0aW9uOjE=']); 215 | 216 | $expected = array ( 217 | 'edges' => 218 | array ( 219 | 0 => 220 | array ( 221 | 'node' => 'C', 222 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 223 | ), 224 | 1 => 225 | array ( 226 | 'node' => 'D', 227 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 228 | ), 229 | ), 230 | 'pageInfo' => 231 | array ( 232 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 233 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 234 | 'hasPreviousPage' => false, 235 | 'hasNextPage' => true, 236 | ), 237 | ); 238 | 239 | $this->assertEquals($connection, $expected); 240 | } 241 | 242 | public function testRespectsFirstAndAfterWithLongFirst() 243 | { 244 | $connection = ArrayConnection::connectionFromArray($this->letters, ['first' => 10, 'after' => 'YXJyYXljb25uZWN0aW9uOjE=']); 245 | 246 | $expected = array ( 247 | 'edges' => 248 | array ( 249 | 0 => 250 | array ( 251 | 'node' => 'C', 252 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 253 | ), 254 | 1 => 255 | array ( 256 | 'node' => 'D', 257 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 258 | ), 259 | 2 => 260 | array ( 261 | 'node' => 'E', 262 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 263 | ), 264 | ), 265 | 'pageInfo' => 266 | array ( 267 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 268 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 269 | 'hasPreviousPage' => false, 270 | 'hasNextPage' => false, 271 | ), 272 | ); 273 | 274 | $this->assertEquals($expected, $connection); 275 | } 276 | 277 | public function testRespectsLastAndBefore() 278 | { 279 | $connection = ArrayConnection::connectionFromArray($this->letters, [ 280 | 'last' => 2, 281 | 'before' => 'YXJyYXljb25uZWN0aW9uOjM=' 282 | ]); 283 | 284 | $expected = array ( 285 | 'edges' => 286 | array ( 287 | 0 => 288 | array ( 289 | 'node' => 'B', 290 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 291 | ), 292 | 1 => 293 | array ( 294 | 'node' => 'C', 295 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 296 | ), 297 | ), 298 | 'pageInfo' => 299 | array ( 300 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 301 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 302 | 'hasPreviousPage' => true, 303 | 'hasNextPage' => false, 304 | ), 305 | ); 306 | 307 | $this->assertEquals($expected, $connection); 308 | } 309 | 310 | public function testRespectsLastAndBeforeWithLongLast() 311 | { 312 | $connection = ArrayConnection::connectionFromArray($this->letters, [ 313 | 'last' => 10, 314 | 'before' => 'YXJyYXljb25uZWN0aW9uOjM=' 315 | ]); 316 | 317 | $expected = array ( 318 | 'edges' => 319 | array ( 320 | 0 => 321 | array ( 322 | 'node' => 'A', 323 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 324 | ), 325 | 1 => 326 | array ( 327 | 'node' => 'B', 328 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 329 | ), 330 | 2 => 331 | array ( 332 | 'node' => 'C', 333 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 334 | ), 335 | ), 336 | 'pageInfo' => 337 | array ( 338 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 339 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 340 | 'hasPreviousPage' => false, 341 | 'hasNextPage' => false, 342 | ), 343 | ); 344 | 345 | $this->assertEquals($expected, $connection); 346 | } 347 | 348 | public function testRespectsFirstAndAfterAndBeforeTooFew() 349 | { 350 | $connection = ArrayConnection::connectionFromArray($this->letters, [ 351 | 'first' => 2, 352 | 'after' => 'YXJyYXljb25uZWN0aW9uOjA=', 353 | 'before' => 'YXJyYXljb25uZWN0aW9uOjQ=' 354 | ]); 355 | 356 | $expected = array ( 357 | 'edges' => 358 | array ( 359 | 0 => 360 | array ( 361 | 'node' => 'B', 362 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 363 | ), 364 | 1 => 365 | array ( 366 | 'node' => 'C', 367 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 368 | ), 369 | ), 370 | 'pageInfo' => 371 | array ( 372 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 373 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 374 | 'hasPreviousPage' => false, 375 | 'hasNextPage' => true, 376 | ), 377 | ); 378 | 379 | $this->assertEquals($expected, $connection); 380 | } 381 | 382 | public function testRespectsFirstAndAfterAndBeforeTooMany() 383 | { 384 | $connection = ArrayConnection::connectionFromArray($this->letters, [ 385 | 'first' => 3, 386 | 'after' => 'YXJyYXljb25uZWN0aW9uOjA=', 387 | 'before' => 'YXJyYXljb25uZWN0aW9uOjQ=' 388 | ]); 389 | 390 | $expected = array ( 391 | 'edges' => 392 | array ( 393 | 0 => 394 | array ( 395 | 'node' => 'B', 396 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 397 | ), 398 | 1 => 399 | array ( 400 | 'node' => 'C', 401 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 402 | ), 403 | 2 => 404 | array ( 405 | 'node' => 'D', 406 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 407 | ), 408 | ), 409 | 'pageInfo' => 410 | array ( 411 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 412 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 413 | 'hasPreviousPage' => false, 414 | 'hasNextPage' => false, 415 | ), 416 | ); 417 | 418 | $this->assertEquals($expected, $connection); 419 | } 420 | 421 | public function testRespectsFirstAndAfterAndBeforeExactlyRight() 422 | { 423 | $connection = ArrayConnection::connectionFromArray($this->letters, [ 424 | 'first' => 3, 425 | 'after' => 'YXJyYXljb25uZWN0aW9uOjA=', 426 | 'before' => 'YXJyYXljb25uZWN0aW9uOjQ=' 427 | ]); 428 | 429 | $expected = array ( 430 | 'edges' => 431 | array ( 432 | 0 => 433 | array ( 434 | 'node' => 'B', 435 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 436 | ), 437 | 1 => 438 | array ( 439 | 'node' => 'C', 440 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 441 | ), 442 | 2 => 443 | array ( 444 | 'node' => 'D', 445 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 446 | ), 447 | ), 448 | 'pageInfo' => 449 | array ( 450 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 451 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 452 | 'hasPreviousPage' => false, 453 | 'hasNextPage' => false, 454 | ), 455 | ); 456 | 457 | $this->assertEquals($expected, $connection); 458 | } 459 | 460 | public function testRespectsLastAndAfterAndBeforeTooFew() 461 | { 462 | $connection = ArrayConnection::connectionFromArray($this->letters, [ 463 | 'last' => 2, 464 | 'after' => 'YXJyYXljb25uZWN0aW9uOjA=', 465 | 'before' => 'YXJyYXljb25uZWN0aW9uOjQ=' 466 | ]); 467 | 468 | $expected = array ( 469 | 'edges' => 470 | array ( 471 | 0 => 472 | array ( 473 | 'node' => 'C', 474 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 475 | ), 476 | 1 => 477 | array ( 478 | 'node' => 'D', 479 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 480 | ), 481 | ), 482 | 'pageInfo' => 483 | array ( 484 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 485 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 486 | 'hasPreviousPage' => true, 487 | 'hasNextPage' => false, 488 | ), 489 | ); 490 | 491 | $this->assertEquals($expected, $connection); 492 | } 493 | 494 | public function testRespectsLastAndAfterAndBeforeTooMany() 495 | { 496 | $connection = ArrayConnection::connectionFromArray($this->letters, [ 497 | 'last' => 4, 498 | 'after' => 'YXJyYXljb25uZWN0aW9uOjA=', 499 | 'before' => 'YXJyYXljb25uZWN0aW9uOjQ=' 500 | ]); 501 | 502 | $expected = array ( 503 | 'edges' => 504 | array ( 505 | 0 => 506 | array ( 507 | 'node' => 'B', 508 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 509 | ), 510 | 1 => 511 | array ( 512 | 'node' => 'C', 513 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 514 | ), 515 | 2 => 516 | array ( 517 | 'node' => 'D', 518 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 519 | ), 520 | ), 521 | 'pageInfo' => 522 | array ( 523 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 524 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 525 | 'hasPreviousPage' => false, 526 | 'hasNextPage' => false, 527 | ), 528 | ); 529 | 530 | $this->assertEquals($expected, $connection); 531 | } 532 | 533 | public function testRespectsLastAndAfterAndBeforeExactlyRight() 534 | { 535 | $connection = ArrayConnection::connectionFromArray($this->letters, [ 536 | 'last' => 3, 537 | 'after' => 'YXJyYXljb25uZWN0aW9uOjA=', 538 | 'before' => 'YXJyYXljb25uZWN0aW9uOjQ=' 539 | ]); 540 | 541 | $expected = array ( 542 | 'edges' => 543 | array ( 544 | 0 => 545 | array ( 546 | 'node' => 'B', 547 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 548 | ), 549 | 1 => 550 | array ( 551 | 'node' => 'C', 552 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 553 | ), 554 | 2 => 555 | array ( 556 | 'node' => 'D', 557 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 558 | ), 559 | ), 560 | 'pageInfo' => 561 | array ( 562 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 563 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 564 | 'hasPreviousPage' => false, 565 | 'hasNextPage' => false, 566 | ), 567 | ); 568 | 569 | $this->assertEquals($expected, $connection); 570 | } 571 | 572 | public function testReturnsNoElementsIfFirstIs0() 573 | { 574 | $connection = ArrayConnection::connectionFromArray($this->letters, [ 575 | 'first' => 0 576 | ]); 577 | 578 | $expected = array ( 579 | 'edges' => 580 | array ( 581 | ), 582 | 'pageInfo' => 583 | array ( 584 | 'startCursor' => NULL, 585 | 'endCursor' => NULL, 586 | 'hasPreviousPage' => false, 587 | 'hasNextPage' => true, 588 | ), 589 | ); 590 | 591 | $this->assertEquals($expected, $connection); 592 | } 593 | 594 | public function testReturnsAllElementsIfCursorsAreInvalid() 595 | { 596 | $connection = ArrayConnection::connectionFromArray($this->letters, [ 597 | 'before' => 'invalid', 598 | 'after' => 'invalid' 599 | ]); 600 | 601 | $expected = array ( 602 | 'edges' => 603 | array ( 604 | 0 => 605 | array ( 606 | 'node' => 'A', 607 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 608 | ), 609 | 1 => 610 | array ( 611 | 'node' => 'B', 612 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 613 | ), 614 | 2 => 615 | array ( 616 | 'node' => 'C', 617 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 618 | ), 619 | 3 => 620 | array ( 621 | 'node' => 'D', 622 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 623 | ), 624 | 4 => 625 | array ( 626 | 'node' => 'E', 627 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 628 | ), 629 | ), 630 | 'pageInfo' => 631 | array ( 632 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 633 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 634 | 'hasPreviousPage' => false, 635 | 'hasNextPage' => false, 636 | ), 637 | ); 638 | 639 | $this->assertEquals($expected, $connection); 640 | } 641 | 642 | public function testReturnsAllElementsIfCursorsAreOnTheOutside() 643 | { 644 | $connection = ArrayConnection::connectionFromArray($this->letters, [ 645 | 'before' => 'YXJyYXljb25uZWN0aW9uOjYK', 646 | 'after' => 'YXJyYXljb25uZWN0aW9uOi0xCg==' 647 | ]); 648 | 649 | $expected = array ( 650 | 'edges' => 651 | array ( 652 | 0 => 653 | array ( 654 | 'node' => 'A', 655 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 656 | ), 657 | 1 => 658 | array ( 659 | 'node' => 'B', 660 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 661 | ), 662 | 2 => 663 | array ( 664 | 'node' => 'C', 665 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 666 | ), 667 | 3 => 668 | array ( 669 | 'node' => 'D', 670 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 671 | ), 672 | 4 => 673 | array ( 674 | 'node' => 'E', 675 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 676 | ), 677 | ), 678 | 'pageInfo' => 679 | array ( 680 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 681 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 682 | 'hasPreviousPage' => false, 683 | 'hasNextPage' => false, 684 | ), 685 | ); 686 | 687 | $this->assertEquals($expected, $connection); 688 | } 689 | 690 | public function testReturnsNoElementsIfCursorsCross() 691 | { 692 | $connection = ArrayConnection::connectionFromArray($this->letters, [ 693 | 'before' => 'YXJyYXljb25uZWN0aW9uOjI=', 694 | 'after' => 'YXJyYXljb25uZWN0aW9uOjQ=' 695 | ]); 696 | 697 | $expected = array ( 698 | 'edges' => 699 | array ( 700 | ), 701 | 'pageInfo' => 702 | array ( 703 | 'startCursor' => NULL, 704 | 'endCursor' => NULL, 705 | 'hasPreviousPage' => false, 706 | 'hasNextPage' => false, 707 | ), 708 | ); 709 | 710 | $this->assertEquals($expected, $connection); 711 | } 712 | 713 | public function testReturnsAnEdgeCursorGivenAnArrayAndAMemberObject() 714 | { 715 | $cursor = ArrayConnection::cursorForObjectInConnection($this->letters, 'B'); 716 | 717 | $this->assertEquals('YXJyYXljb25uZWN0aW9uOjE=', $cursor); 718 | } 719 | 720 | public function testReturnsNullGivenAnArrayAndANonMemberObject() 721 | { 722 | $cursor = ArrayConnection::cursorForObjectInConnection($this->letters, 'F'); 723 | 724 | $this->assertEquals(null, $cursor); 725 | } 726 | 727 | public function testWorksWithAJustRightArraySlice() 728 | { 729 | $connection = ArrayConnection::connectionFromArraySlice( 730 | array_slice($this->letters, 1, 2), 731 | [ 732 | 'first' => 2, 733 | 'after' => 'YXJyYXljb25uZWN0aW9uOjA=', 734 | ], 735 | [ 736 | 'sliceStart' => 1, 737 | 'arrayLength' => 5 738 | ] 739 | ); 740 | 741 | $expected = array ( 742 | 'edges' => 743 | array ( 744 | 0 => 745 | array ( 746 | 'node' => 'B', 747 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 748 | ), 749 | 1 => 750 | array ( 751 | 'node' => 'C', 752 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 753 | ), 754 | ), 755 | 'pageInfo' => 756 | array ( 757 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 758 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 759 | 'hasPreviousPage' => false, 760 | 'hasNextPage' => true, 761 | ), 762 | ); 763 | 764 | $this->assertEquals($expected, $connection); 765 | } 766 | 767 | public function testWorksWithAnOversizedArraySliceLeftSide() 768 | { 769 | $connection = ArrayConnection::connectionFromArraySlice( 770 | array_slice($this->letters, 0, 3), 771 | [ 772 | 'first' => 2, 773 | 'after' => 'YXJyYXljb25uZWN0aW9uOjA=', 774 | ], 775 | [ 776 | 'sliceStart' => 0, 777 | 'arrayLength' => 5 778 | ] 779 | ); 780 | 781 | $expected = array ( 782 | 'edges' => 783 | array ( 784 | 0 => 785 | array ( 786 | 'node' => 'B', 787 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 788 | ), 789 | 1 => 790 | array ( 791 | 'node' => 'C', 792 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 793 | ), 794 | ), 795 | 'pageInfo' => 796 | array ( 797 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 798 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 799 | 'hasPreviousPage' => false, 800 | 'hasNextPage' => true, 801 | ), 802 | ); 803 | 804 | $this->assertEquals($expected, $connection); 805 | } 806 | 807 | public function testWorksWithAnOversizedArraySliceRightSide() 808 | { 809 | $connection = ArrayConnection::connectionFromArraySlice( 810 | array_slice($this->letters, 2, 2), 811 | [ 812 | 'first' => 1, 813 | 'after' => 'YXJyYXljb25uZWN0aW9uOjE=', 814 | ], 815 | [ 816 | 'sliceStart' => 2, 817 | 'arrayLength' => 5 818 | ] 819 | ); 820 | 821 | $expected = array ( 822 | 'edges' => 823 | array ( 824 | 0 => 825 | array ( 826 | 'node' => 'C', 827 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 828 | ), 829 | ), 830 | 'pageInfo' => 831 | array ( 832 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 833 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 834 | 'hasPreviousPage' => false, 835 | 'hasNextPage' => true, 836 | ), 837 | ); 838 | 839 | $this->assertEquals($expected, $connection); 840 | } 841 | 842 | public function testWorksWithAnOversizedArraySliceBothSides() 843 | { 844 | $connection = ArrayConnection::connectionFromArraySlice( 845 | array_slice($this->letters, 1, 3), 846 | [ 847 | 'first' => 1, 848 | 'after' => 'YXJyYXljb25uZWN0aW9uOjE=', 849 | ], 850 | [ 851 | 'sliceStart' => 1, 852 | 'arrayLength' => 5 853 | ] 854 | ); 855 | 856 | $expected = array ( 857 | 'edges' => 858 | array ( 859 | 0 => 860 | array ( 861 | 'node' => 'C', 862 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 863 | ), 864 | ), 865 | 'pageInfo' => 866 | array ( 867 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 868 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 869 | 'hasPreviousPage' => false, 870 | 'hasNextPage' => true, 871 | ), 872 | ); 873 | 874 | $this->assertEquals($expected, $connection); 875 | } 876 | 877 | public function testWorksWithAnUndersizedArraySliceLeftSide() 878 | { 879 | $connection = ArrayConnection::connectionFromArraySlice( 880 | array_slice($this->letters, 3, 2), 881 | [ 882 | 'first' => 3, 883 | 'after' => 'YXJyYXljb25uZWN0aW9uOjE=', 884 | ], 885 | [ 886 | 'sliceStart' => 3, 887 | 'arrayLength' => 5 888 | ] 889 | ); 890 | 891 | $expected = array ( 892 | 'edges' => 893 | array ( 894 | 0 => 895 | array ( 896 | 'node' => 'D', 897 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 898 | ), 899 | 1 => 900 | array ( 901 | 'node' => 'E', 902 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 903 | ), 904 | ), 905 | 'pageInfo' => 906 | array ( 907 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 908 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 909 | 'hasPreviousPage' => false, 910 | 'hasNextPage' => false, 911 | ), 912 | ); 913 | 914 | $this->assertEquals($expected, $connection); 915 | } 916 | 917 | public function testWorksWithAnUndersizedArraySliceRightSide() 918 | { 919 | $connection = ArrayConnection::connectionFromArraySlice( 920 | array_slice($this->letters, 2, 2), 921 | [ 922 | 'first' => 3, 923 | 'after' => 'YXJyYXljb25uZWN0aW9uOjE=', 924 | ], 925 | [ 926 | 'sliceStart' => 2, 927 | 'arrayLength' => 5 928 | ] 929 | ); 930 | 931 | $expected = array ( 932 | 'edges' => 933 | array ( 934 | 0 => 935 | array ( 936 | 'node' => 'C', 937 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 938 | ), 939 | 1 => 940 | array ( 941 | 'node' => 'D', 942 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 943 | ), 944 | ), 945 | 'pageInfo' => 946 | array ( 947 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 948 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 949 | 'hasPreviousPage' => false, 950 | 'hasNextPage' => true, 951 | ), 952 | ); 953 | 954 | $this->assertEquals($expected, $connection); 955 | } 956 | 957 | public function testWorksWithAnUndersizedArraySliceBothSides() 958 | { 959 | $connection = ArrayConnection::connectionFromArraySlice( 960 | array_slice($this->letters, 3, 1), 961 | [ 962 | 'first' => 3, 963 | 'after' => 'YXJyYXljb25uZWN0aW9uOjE=', 964 | ], 965 | [ 966 | 'sliceStart' => 3, 967 | 'arrayLength' => 5 968 | ] 969 | ); 970 | 971 | $expected = array ( 972 | 'edges' => 973 | array ( 974 | 0 => 975 | array ( 976 | 'node' => 'D', 977 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 978 | ), 979 | ), 980 | 'pageInfo' => 981 | array ( 982 | 'startCursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 983 | 'endCursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 984 | 'hasPreviousPage' => false, 985 | 'hasNextPage' => true, 986 | ), 987 | ); 988 | 989 | $this->assertEquals($expected, $connection); 990 | } 991 | } 992 | -------------------------------------------------------------------------------- /tests/Connection/ConnectionTest.php: -------------------------------------------------------------------------------- 1 | allUsers = [ 52 | [ 'name' => 'Dan', 'friends' => [1, 2, 3, 4] ], 53 | [ 'name' => 'Nick', 'friends' => [0, 2, 3, 4] ], 54 | [ 'name' => 'Lee', 'friends' => [0, 1, 3, 4] ], 55 | [ 'name' => 'Joe', 'friends' => [0, 1, 2, 4] ], 56 | [ 'name' => 'Tim', 'friends' => [0, 1, 2, 3] ], 57 | ]; 58 | 59 | $this->userType = new ObjectType([ 60 | 'name' => 'User', 61 | 'fields' => function(){ 62 | return [ 63 | 'name' => [ 64 | 'type' => Type::string() 65 | ], 66 | 'friends' => [ 67 | 'type' => $this->friendConnection, 68 | 'args' => Connection::connectionArgs(), 69 | 'resolve' => function ($user, $args) { 70 | return ArrayConnection::connectionFromArray($user['friends'], $args); 71 | } 72 | ], 73 | 'friendsForward' => [ 74 | 'type' => $this->userConnection, 75 | 'args' => Connection::forwardConnectionArgs(), 76 | 'resolve' => function ($user, $args) { 77 | return ArrayConnection::connectionFromArray($user['friends'], $args); 78 | } 79 | ], 80 | 'friendsBackward' => [ 81 | 'type' => $this->userConnection, 82 | 'args' => Connection::backwardConnectionArgs(), 83 | 'resolve' => function ($user, $args) { 84 | return ArrayConnection::connectionFromArray($user['friends'], $args); 85 | } 86 | ] 87 | ]; 88 | } 89 | ]); 90 | 91 | $this->friendConnection = Connection::connectionDefinitions([ 92 | 'name' => 'Friend', 93 | 'nodeType' => $this->userType, 94 | 'resolveNode' => function ($edge) { 95 | return $this->allUsers[$edge['node']]; 96 | }, 97 | 'edgeFields' => function() { 98 | return [ 99 | 'friendshipTime' => [ 100 | 'type' => Type::string(), 101 | 'resolve' => function() { return 'Yesterday'; } 102 | ] 103 | ]; 104 | }, 105 | 'connectionFields' => function() { 106 | return [ 107 | 'totalCount' => [ 108 | 'type' => Type::int(), 109 | 'resolve' => function() { 110 | return count($this->allUsers) -1; 111 | } 112 | ] 113 | ]; 114 | } 115 | ])['connectionType']; 116 | 117 | $this->userConnection = Connection::connectionDefinitions([ 118 | 'nodeType' => $this->userType, 119 | 'resolveNode' => function ($edge) { 120 | return $this->allUsers[$edge['node']]; 121 | } 122 | ])['connectionType']; 123 | 124 | $this->queryType = new ObjectType([ 125 | 'name' => 'Query', 126 | 'fields' => function() { 127 | return [ 128 | 'user' => [ 129 | 'type' => $this->userType, 130 | 'resolve' => function() { 131 | return $this->allUsers[0]; 132 | } 133 | ] 134 | ]; 135 | } 136 | ]); 137 | 138 | $this->schema = new Schema([ 139 | 'query' => $this->queryType 140 | ]); 141 | } 142 | 143 | public function testIncludesConnectionAndEdgeFields() 144 | { 145 | $query = 'query FriendsQuery { 146 | user { 147 | friends(first: 2) { 148 | totalCount 149 | edges { 150 | friendshipTime 151 | node { 152 | name 153 | } 154 | } 155 | } 156 | } 157 | }'; 158 | 159 | $expected = [ 160 | 'user' => [ 161 | 'friends' => [ 162 | 'totalCount' => 4, 163 | 'edges' => [ 164 | [ 165 | 'friendshipTime' => 'Yesterday', 166 | 'node' => [ 167 | 'name' => 'Nick' 168 | ] 169 | ], 170 | [ 171 | 'friendshipTime' => 'Yesterday', 172 | 'node' => [ 173 | 'name' => 'Lee' 174 | ] 175 | ] 176 | ] 177 | ] 178 | ] 179 | ]; 180 | 181 | $this->assertValidQuery($query, $expected); 182 | } 183 | 184 | public function testWorksWithForwardConnectionArgs() 185 | { 186 | $query = 'query FriendsQuery { 187 | user { 188 | friendsForward(first: 2) { 189 | edges { 190 | node { 191 | name 192 | } 193 | } 194 | } 195 | } 196 | }'; 197 | $expected = [ 198 | 'user' => [ 199 | 'friendsForward' => [ 200 | 'edges' => [ 201 | [ 202 | 'node' => [ 203 | 'name' => 'Nick' 204 | ] 205 | ], 206 | [ 207 | 'node' => [ 208 | 'name' => 'Lee' 209 | ] 210 | ] 211 | ] 212 | ] 213 | ] 214 | ]; 215 | 216 | $this->assertValidQuery($query, $expected); 217 | } 218 | 219 | public function testWorksWithBackwardConnectionArgs() 220 | { 221 | $query = 'query FriendsQuery { 222 | user { 223 | friendsBackward(last: 2) { 224 | edges { 225 | node { 226 | name 227 | } 228 | } 229 | } 230 | } 231 | }'; 232 | 233 | $expected = [ 234 | 'user' => [ 235 | 'friendsBackward' => [ 236 | 'edges' => [ 237 | [ 238 | 'node' => [ 239 | 'name' => 'Joe' 240 | ] 241 | ], 242 | [ 243 | 'node' => [ 244 | 'name' => 'Tim' 245 | ] 246 | ] 247 | ] 248 | ] 249 | ] 250 | ]; 251 | 252 | $this->assertValidQuery($query, $expected); 253 | } 254 | 255 | public function testEdgeTypeThrowsWithoutNodeType() { 256 | $this->expectException(\InvalidArgumentException::class); 257 | Connection::createEdgeType([]); 258 | } 259 | 260 | public function testConnectionTypeThrowsWithoutNodeType() { 261 | $this->expectException(\InvalidArgumentException::class); 262 | Connection::createConnectionType([]); 263 | } 264 | 265 | public function testConnectionDefinitionThrowsWithoutNodeType() { 266 | $this->expectException(\InvalidArgumentException::class); 267 | Connection::connectionDefinitions([]); 268 | } 269 | 270 | /** 271 | * Helper function to test a query and the expected response. 272 | */ 273 | protected function assertValidQuery($query, $expected) 274 | { 275 | $result = GraphQL::executeQuery($this->schema, $query)->toArray(); 276 | $this->assertEquals(['data' => $expected], $result); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /tests/Connection/SeparateConnectionTest.php: -------------------------------------------------------------------------------- 1 | allUsers = [ 62 | [ 'name' => 'Dan', 'friends' => [1, 2, 3, 4] ], 63 | [ 'name' => 'Nick', 'friends' => [0, 2, 3, 4] ], 64 | [ 'name' => 'Lee', 'friends' => [0, 1, 3, 4] ], 65 | [ 'name' => 'Joe', 'friends' => [0, 1, 2, 4] ], 66 | [ 'name' => 'Tim', 'friends' => [0, 1, 2, 3] ], 67 | ]; 68 | 69 | $this->userType = new ObjectType([ 70 | 'name' => 'User', 71 | 'fields' => function(){ 72 | return [ 73 | 'name' => [ 74 | 'type' => Type::string() 75 | ], 76 | 'friends' => [ 77 | 'type' => $this->friendConnection, 78 | 'args' => Connection::connectionArgs(), 79 | 'resolve' => function ($user, $args) { 80 | return ArrayConnection::connectionFromArray($user['friends'], $args); 81 | } 82 | ], 83 | 'friendsForward' => [ 84 | 'type' => $this->userConnection, 85 | 'args' => Connection::forwardConnectionArgs(), 86 | 'resolve' => function ($user, $args) { 87 | return ArrayConnection::connectionFromArray($user['friends'], $args); 88 | } 89 | ], 90 | 'friendsBackward' => [ 91 | 'type' => $this->userConnection, 92 | 'args' => Connection::backwardConnectionArgs(), 93 | 'resolve' => function ($user, $args) { 94 | return ArrayConnection::connectionFromArray($user['friends'], $args); 95 | } 96 | ] 97 | ]; 98 | } 99 | ]); 100 | 101 | $this->friendEdge = Connection::createEdgeType([ 102 | 'name' => 'Friend', 103 | 'nodeType' => $this->userType, 104 | 'resolveNode' => function ($edge) { 105 | return $this->allUsers[$edge['node']]; 106 | }, 107 | 'edgeFields' => function() { 108 | return [ 109 | 'friendshipTime' => [ 110 | 'type' => Type::string(), 111 | 'resolve' => function() { return 'Yesterday'; } 112 | ] 113 | ]; 114 | } 115 | ]); 116 | 117 | $this->friendConnection = Connection::createConnectionType([ 118 | 'name' => 'Friend', 119 | 'nodeType' => $this->userType, 120 | 'edgeType' => $this->friendEdge, 121 | 'connectionFields' => function() { 122 | return [ 123 | 'totalCount' => [ 124 | 'type' => Type::int(), 125 | 'resolve' => function() { 126 | return count($this->allUsers) -1; 127 | } 128 | ] 129 | ]; 130 | } 131 | ]); 132 | 133 | $this->userEdge = Connection::createEdgeType([ 134 | 'nodeType' => $this->userType, 135 | 'resolveNode' => function ($edge) { 136 | return $this->allUsers[$edge['node']]; 137 | } 138 | ]); 139 | 140 | $this->userConnection = Connection::createConnectionType([ 141 | 'nodeType' => $this->userType, 142 | 'edgeType' => $this->userEdge 143 | ]); 144 | 145 | $this->queryType = new ObjectType([ 146 | 'name' => 'Query', 147 | 'fields' => function() { 148 | return [ 149 | 'user' => [ 150 | 'type' => $this->userType, 151 | 'resolve' => function() { 152 | return $this->allUsers[0]; 153 | } 154 | ] 155 | ]; 156 | } 157 | ]); 158 | 159 | $this->schema = new Schema([ 160 | 'query' => $this->queryType 161 | ]); 162 | } 163 | 164 | public function testIncludesConnectionAndEdgeFields() 165 | { 166 | $query = 'query FriendsQuery { 167 | user { 168 | friends(first: 2) { 169 | totalCount 170 | edges { 171 | friendshipTime 172 | node { 173 | name 174 | } 175 | } 176 | } 177 | } 178 | }'; 179 | 180 | $expected = [ 181 | 'user' => [ 182 | 'friends' => [ 183 | 'totalCount' => 4, 184 | 'edges' => [ 185 | [ 186 | 'friendshipTime' => 'Yesterday', 187 | 'node' => [ 188 | 'name' => 'Nick' 189 | ] 190 | ], 191 | [ 192 | 'friendshipTime' => 'Yesterday', 193 | 'node' => [ 194 | 'name' => 'Lee' 195 | ] 196 | ] 197 | ] 198 | ] 199 | ] 200 | ]; 201 | 202 | $this->assertValidQuery($query, $expected); 203 | } 204 | 205 | public function testWorksWithForwardConnectionArgs() 206 | { 207 | $query = 'query FriendsQuery { 208 | user { 209 | friendsForward(first: 2) { 210 | edges { 211 | node { 212 | name 213 | } 214 | } 215 | } 216 | } 217 | }'; 218 | $expected = [ 219 | 'user' => [ 220 | 'friendsForward' => [ 221 | 'edges' => [ 222 | [ 223 | 'node' => [ 224 | 'name' => 'Nick' 225 | ] 226 | ], 227 | [ 228 | 'node' => [ 229 | 'name' => 'Lee' 230 | ] 231 | ] 232 | ] 233 | ] 234 | ] 235 | ]; 236 | 237 | $this->assertValidQuery($query, $expected); 238 | } 239 | 240 | public function testWorksWithBackwardConnectionArgs() 241 | { 242 | $query = 'query FriendsQuery { 243 | user { 244 | friendsBackward(last: 2) { 245 | edges { 246 | node { 247 | name 248 | } 249 | } 250 | } 251 | } 252 | }'; 253 | 254 | $expected = [ 255 | 'user' => [ 256 | 'friendsBackward' => [ 257 | 'edges' => [ 258 | [ 259 | 'node' => [ 260 | 'name' => 'Joe' 261 | ] 262 | ], 263 | [ 264 | 'node' => [ 265 | 'name' => 'Tim' 266 | ] 267 | ] 268 | ] 269 | ] 270 | ] 271 | ]; 272 | 273 | $this->assertValidQuery($query, $expected); 274 | } 275 | 276 | /** 277 | * Helper function to test a query and the expected response. 278 | */ 279 | protected function assertValidQuery($query, $expected) 280 | { 281 | $result = GraphQL::executeQuery($this->schema, $query)->toArray(); 282 | $this->assertEquals(['data' => $expected], $result); 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /tests/Mutation/MutationTest.php: -------------------------------------------------------------------------------- 1 | simpleMutation = Mutation::mutationWithClientMutationId([ 58 | 'name' => 'SimpleMutation', 59 | 'inputFields' => [], 60 | 'outputFields' => [ 61 | 'result' => [ 62 | 'type' => Type::int() 63 | ] 64 | ], 65 | 'mutateAndGetPayload' => function () { 66 | return ['result' => 1]; 67 | } 68 | ]); 69 | 70 | $this->simpleMutationWithDescription = Mutation::mutationWithClientMutationId([ 71 | 'name' => 'SimpleMutationWithDescription', 72 | 'description' => 'Simple Mutation Description', 73 | 'inputFields' => [], 74 | 'outputFields' => [ 75 | 'result' => [ 76 | 'type' => Type::int() 77 | ] 78 | ], 79 | 'mutateAndGetPayload' => function () { 80 | return ['result' => 1]; 81 | } 82 | ]); 83 | 84 | $this->simpleMutationWithDeprecationReason = Mutation::mutationWithClientMutationId([ 85 | 'name' => 'SimpleMutationWithDeprecationReason', 86 | 'inputFields' => [], 87 | 'outputFields' => [ 88 | 'result' => [ 89 | 'type' => Type::int() 90 | ] 91 | ], 92 | 'mutateAndGetPayload' => function () { 93 | return ['result' => 1]; 94 | }, 95 | 'deprecationReason' => 'Just because' 96 | ]); 97 | 98 | $this->simpleMutationWithThunkFields = Mutation::mutationWithClientMutationId([ 99 | 'name' => 'SimpleMutationWithThunkFields', 100 | 'inputFields' => function() { 101 | return [ 102 | 'inputData' => [ 103 | 'type' => Type::int() 104 | ] 105 | ]; 106 | }, 107 | 'outputFields' => function() { 108 | return [ 109 | 'result' => [ 110 | 'type' => Type::int() 111 | ] 112 | ]; 113 | }, 114 | 'mutateAndGetPayload' => function($inputData) { 115 | return [ 116 | 'result' => $inputData['inputData'] 117 | ]; 118 | } 119 | ]); 120 | 121 | $userType = new ObjectType([ 122 | 'name' => 'User', 123 | 'fields' => [ 124 | 'name' => [ 125 | 'type' => Type::string() 126 | ] 127 | ] 128 | ]); 129 | 130 | $this->edgeMutation = Mutation::mutationWithClientMutationId([ 131 | 'name' => 'EdgeMutation', 132 | 'inputFields' => [], 133 | 'outputFields' => [ 134 | 'result' => [ 135 | 'type' => Connection::createEdgeType(['nodeType' => $userType ]) 136 | ] 137 | ], 138 | 'mutateAndGetPayload' => function () { 139 | return ['result' => ['node' => ['name' => 'Robert'], 'cursor' => 'SWxvdmVHcmFwaFFM']]; 140 | } 141 | ]); 142 | 143 | $this->mutation = new ObjectType([ 144 | 'name' => 'Mutation', 145 | 'fields' => [ 146 | 'simpleMutation' => $this->simpleMutation, 147 | 'simpleMutationWithDescription' => $this->simpleMutationWithDescription, 148 | 'simpleMutationWithDeprecationReason' => $this->simpleMutationWithDeprecationReason, 149 | 'simpleMutationWithThunkFields' => $this->simpleMutationWithThunkFields, 150 | 'edgeMutation' => $this->edgeMutation 151 | ] 152 | ]); 153 | 154 | $this->schema = new Schema([ 155 | 'mutation' => $this->mutation, 156 | 'query' => $this->mutation 157 | ]); 158 | } 159 | 160 | public function testRequiresAnArgument() { 161 | $query = 'mutation M { 162 | simpleMutation { 163 | result 164 | } 165 | }'; 166 | 167 | $result = GraphQL::executeQuery($this->schema, $query)->toArray(); 168 | 169 | $this->assertEquals(count($result['errors']), 1); 170 | $this->assertEquals($result['errors'][0]['message'], 'Field "simpleMutation" argument "input" of type "SimpleMutationInput!" is required but not provided.'); 171 | } 172 | 173 | public function testReturnsTheSameClientMutationID() 174 | { 175 | $query = 'mutation M { 176 | simpleMutation(input: {clientMutationId: "abc"}) { 177 | result 178 | clientMutationId 179 | } 180 | }'; 181 | 182 | $expected = [ 183 | 'simpleMutation' => [ 184 | 'result' => 1, 185 | 'clientMutationId' => 'abc' 186 | ] 187 | ]; 188 | 189 | $this->assertValidQuery($query, $expected); 190 | } 191 | 192 | public function testReturnsNullWithOmittedClientMutationID() 193 | { 194 | $query = 'mutation M { 195 | simpleMutation(input: {}) { 196 | result 197 | clientMutationId 198 | } 199 | }'; 200 | 201 | $expected = [ 202 | 'simpleMutation' => [ 203 | 'result' => 1, 204 | 'clientMutationId' => null 205 | ] 206 | ]; 207 | 208 | $this->assertValidQuery($query, $expected); 209 | } 210 | 211 | public function testSupportsEdgeAsOutputField() 212 | { 213 | $query = 'mutation M { 214 | edgeMutation(input: {clientMutationId: "abc"}) { 215 | result { 216 | node { 217 | name 218 | } 219 | cursor 220 | } 221 | clientMutationId 222 | } 223 | }'; 224 | 225 | $expected = [ 226 | 'edgeMutation' => [ 227 | 'result' => [ 228 | 'node' => ['name' => 'Robert'], 229 | 'cursor' => 'SWxvdmVHcmFwaFFM' 230 | ], 231 | 'clientMutationId' => 'abc' 232 | ] 233 | ]; 234 | 235 | $this->assertValidQuery($query, $expected); 236 | } 237 | 238 | public function testIntrospection() 239 | { 240 | $query = '{ 241 | __type(name: "SimpleMutationInput") { 242 | name 243 | kind 244 | inputFields { 245 | name 246 | type { 247 | name 248 | kind 249 | ofType { 250 | name 251 | kind 252 | } 253 | } 254 | } 255 | } 256 | }'; 257 | 258 | $expected = [ 259 | '__type' => [ 260 | 'name' => 'SimpleMutationInput', 261 | 'kind' => 'INPUT_OBJECT', 262 | 'inputFields' => [ 263 | [ 264 | 'name' => 'clientMutationId', 265 | 'type' => [ 266 | 'name' => 'String', 267 | 'kind' => 'SCALAR', 268 | 'ofType' => null 269 | ] 270 | ] 271 | ] 272 | ] 273 | ]; 274 | 275 | $this->assertValidQuery($query, $expected); 276 | } 277 | 278 | public function testContainsCorrectPayload() { 279 | $query = '{ 280 | __type(name: "SimpleMutationPayload") { 281 | name 282 | kind 283 | fields { 284 | name 285 | type { 286 | name 287 | kind 288 | ofType { 289 | name 290 | kind 291 | } 292 | } 293 | } 294 | } 295 | }'; 296 | 297 | $expected = [ 298 | '__type' => [ 299 | 'name' => 'SimpleMutationPayload', 300 | 'kind' => 'OBJECT', 301 | 'fields' => [ 302 | [ 303 | 'name' => 'result', 304 | 'type' => [ 305 | 'name' => 'Int', 306 | 'kind' => 'SCALAR', 307 | 'ofType' => null 308 | ] 309 | ], 310 | [ 311 | 'name' => 'clientMutationId', 312 | 'type' => [ 313 | 'name' => 'String', 314 | 'kind' => 'SCALAR', 315 | 'ofType' => null 316 | ] 317 | ] 318 | ] 319 | ] 320 | ]; 321 | 322 | $this->assertValidQuery($query, $expected); 323 | } 324 | 325 | public function testContainsCorrectField() 326 | { 327 | $query = '{ 328 | __schema { 329 | mutationType { 330 | fields { 331 | name 332 | args { 333 | name 334 | type { 335 | name 336 | kind 337 | ofType { 338 | name 339 | kind 340 | } 341 | } 342 | } 343 | type { 344 | name 345 | kind 346 | } 347 | } 348 | } 349 | } 350 | }'; 351 | 352 | $expected = [ 353 | '__schema' => [ 354 | 'mutationType' => [ 355 | 'fields' => [ 356 | [ 357 | 'name' => 'simpleMutation', 358 | 'args' => [ 359 | [ 360 | 'name' => 'input', 361 | 'type' => [ 362 | 'name' => null, 363 | 'kind' => 'NON_NULL', 364 | 'ofType' => [ 365 | 'name' => 'SimpleMutationInput', 366 | 'kind' => 'INPUT_OBJECT' 367 | ] 368 | ], 369 | ] 370 | ], 371 | 'type' => [ 372 | 'name' => 'SimpleMutationPayload', 373 | 'kind' => 'OBJECT', 374 | ] 375 | ], 376 | [ 377 | 'name' => 'simpleMutationWithDescription', 378 | 'args' => [ 379 | [ 380 | 'name' => 'input', 381 | 'type' => [ 382 | 'name' => null, 383 | 'kind' => 'NON_NULL', 384 | 'ofType' => [ 385 | 'name' => 'SimpleMutationWithDescriptionInput', 386 | 'kind' => 'INPUT_OBJECT' 387 | ] 388 | ], 389 | ] 390 | ], 391 | 'type' => [ 392 | 'name' => 'SimpleMutationWithDescriptionPayload', 393 | 'kind' => 'OBJECT', 394 | ] 395 | ], 396 | [ 397 | 'name' => 'simpleMutationWithThunkFields', 398 | 'args' => [ 399 | [ 400 | 'name' => 'input', 401 | 'type' => [ 402 | 'name' => null, 403 | 'kind' => 'NON_NULL', 404 | 'ofType' => [ 405 | 'name' => 'SimpleMutationWithThunkFieldsInput', 406 | 'kind' => 'INPUT_OBJECT' 407 | ] 408 | ], 409 | ] 410 | ], 411 | 'type' => [ 412 | 'name' => 'SimpleMutationWithThunkFieldsPayload', 413 | 'kind' => 'OBJECT', 414 | ] 415 | ], 416 | [ 417 | 'name' => 'edgeMutation', 418 | 'args' => [ 419 | [ 420 | 'name' => 'input', 421 | 'type' => [ 422 | 'name' => null, 423 | 'kind' => 'NON_NULL', 424 | 'ofType' => [ 425 | 'name' => 'EdgeMutationInput', 426 | 'kind' => 'INPUT_OBJECT' 427 | ] 428 | ], 429 | ] 430 | ], 431 | 'type' => [ 432 | 'name' => 'EdgeMutationPayload', 433 | 'kind' => 'OBJECT', 434 | ] 435 | ], 436 | /* 437 | * Promises not implemented right now 438 | [ 439 | 'name' => 'simplePromiseMutation', 440 | 'args' => [ 441 | [ 442 | 'name' => 'input', 443 | 'type' => [ 444 | 'name' => null, 445 | 'kind' => 'NON_NULL', 446 | 'ofType' => [ 447 | 'name' => 'SimplePromiseMutationInput', 448 | 'kind' => 'INPUT_OBJECT' 449 | ] 450 | ], 451 | ] 452 | ], 453 | 'type' => [ 454 | 'name' => 'SimplePromiseMutationPayload', 455 | 'kind' => 'OBJECT', 456 | ] 457 | ]*/ 458 | ] 459 | ] 460 | ] 461 | ]; 462 | 463 | $result = GraphQL::executeQuery($this->schema, $query)->toArray(); 464 | 465 | $this->assertValidQuery($query, $expected); 466 | } 467 | 468 | public function testContainsCorrectDescriptions() { 469 | $query = '{ 470 | __schema { 471 | mutationType { 472 | fields { 473 | name 474 | description 475 | } 476 | } 477 | } 478 | }'; 479 | 480 | $expected = [ 481 | '__schema' => [ 482 | 'mutationType' => [ 483 | 'fields' => [ 484 | [ 485 | 'name' => 'simpleMutation', 486 | 'description' => null 487 | ], 488 | [ 489 | 'name' => 'simpleMutationWithDescription', 490 | 'description' => 'Simple Mutation Description' 491 | ], 492 | [ 493 | 'name' => 'simpleMutationWithThunkFields', 494 | 'description' => null 495 | ], 496 | [ 497 | 'name' => 'edgeMutation', 498 | 'description' => null 499 | ] 500 | ] 501 | ] 502 | ] 503 | ]; 504 | 505 | $this->assertValidQuery($query, $expected); 506 | } 507 | 508 | public function testContainsCorrectDeprecationReasons() { 509 | $query = '{ 510 | __schema { 511 | mutationType { 512 | fields(includeDeprecated: true) { 513 | name 514 | isDeprecated 515 | deprecationReason 516 | } 517 | } 518 | } 519 | }'; 520 | 521 | $expected = [ 522 | '__schema' => [ 523 | 'mutationType' => [ 524 | 'fields' => [ 525 | [ 526 | 'name' => 'simpleMutation', 527 | 'isDeprecated' => false, 528 | 'deprecationReason' => null 529 | ], 530 | [ 531 | 'name' => 'simpleMutationWithDescription', 532 | 'isDeprecated' => false, 533 | 'deprecationReason' => null 534 | ], 535 | [ 536 | 'name' => 'simpleMutationWithDeprecationReason', 537 | 'isDeprecated' => true, 538 | 'deprecationReason' => 'Just because', 539 | ], 540 | [ 541 | 'name' => 'simpleMutationWithThunkFields', 542 | 'isDeprecated' => false, 543 | 'deprecationReason' => null 544 | ], 545 | [ 546 | 'name' => 'edgeMutation', 547 | 'isDeprecated' => false, 548 | 'deprecationReason' => null 549 | ] 550 | ] 551 | ] 552 | ] 553 | ]; 554 | 555 | $this->assertValidQuery($query, $expected); 556 | } 557 | 558 | /** 559 | * Helper function to test a query and the expected response. 560 | */ 561 | protected function assertValidQuery($query, $expected) 562 | { 563 | $this->assertEquals(['data' => $expected], GraphQL::executeQuery($this->schema, $query)->toArray()); 564 | } 565 | } 566 | -------------------------------------------------------------------------------- /tests/Node/NodeTest.php: -------------------------------------------------------------------------------- 1 | [ 47 | 'id' => 1 48 | ] 49 | ]; 50 | 51 | $this->assertValidQuery($query, $expected); 52 | } 53 | 54 | public function testGetsCorrectIDForPhotos() { 55 | $query = '{ 56 | node(id: "4") { 57 | id 58 | } 59 | }'; 60 | 61 | $expected = [ 62 | 'node' => [ 63 | 'id' => 4 64 | ] 65 | ]; 66 | 67 | $this->assertValidQuery($query, $expected); 68 | } 69 | 70 | public function testGetsCorrectNameForUsers() { 71 | $query = '{ 72 | node(id: "1") { 73 | id 74 | ... on User { 75 | name 76 | } 77 | } 78 | }'; 79 | 80 | $expected = [ 81 | 'node' => [ 82 | 'id' => '1', 83 | 'name' => 'John Doe' 84 | ] 85 | ]; 86 | 87 | $this->assertValidQuery($query, $expected); 88 | } 89 | 90 | public function testGetsCorrectWidthForPhotos() { 91 | $query = '{ 92 | node(id: "4") { 93 | id 94 | ... on Photo { 95 | width 96 | } 97 | } 98 | }'; 99 | 100 | $expected = [ 101 | 'node' => [ 102 | 'id' => '4', 103 | 'width' => 400 104 | ] 105 | ]; 106 | 107 | $this->assertValidQuery($query, $expected); 108 | } 109 | 110 | public function testGetsCorrectTypeNameForUsers() { 111 | $query = '{ 112 | node(id: "1") { 113 | id 114 | __typename 115 | } 116 | }'; 117 | 118 | $expected = [ 119 | 'node' => [ 120 | 'id' => '1', 121 | '__typename' => 'User' 122 | ] 123 | ]; 124 | 125 | $this->assertValidQuery($query, $expected); 126 | } 127 | 128 | public function testCorrectWidthForPhotos() { 129 | $query = '{ 130 | node(id: "4") { 131 | id 132 | __typename 133 | } 134 | }'; 135 | 136 | $expected = [ 137 | 'node' => [ 138 | 'id' => '4', 139 | '__typename' => 'Photo' 140 | ] 141 | ]; 142 | 143 | $this->assertValidQuery($query, $expected); 144 | } 145 | 146 | public function testIgnoresPhotoFragmentsOnUser() { 147 | $query = '{ 148 | node(id: "1") { 149 | id 150 | ... on Photo { 151 | width 152 | } 153 | } 154 | }'; 155 | $expected = [ 156 | 'node' => [ 157 | 'id' => '1' 158 | ] 159 | ]; 160 | 161 | $this->assertValidQuery($query, $expected); 162 | } 163 | 164 | public function testReturnsNullForBadIDs() { 165 | $query = '{ 166 | node(id: "5") { 167 | id 168 | } 169 | }'; 170 | 171 | $expected = [ 172 | 'node' => null 173 | ]; 174 | 175 | $this->assertValidQuery($query, $expected); 176 | } 177 | 178 | public function testHasCorrectNodeInterface() { 179 | $query = '{ 180 | __type(name: "Node") { 181 | name 182 | kind 183 | fields { 184 | name 185 | type { 186 | kind 187 | ofType { 188 | name 189 | kind 190 | } 191 | } 192 | } 193 | } 194 | }'; 195 | 196 | $expected = [ 197 | '__type' => [ 198 | 'name' => 'Node', 199 | 'kind' => 'INTERFACE', 200 | 'fields' => [ 201 | [ 202 | 'name' => 'id', 203 | 'type' => [ 204 | 'kind' => 'NON_NULL', 205 | 'ofType' => [ 206 | 'name' => 'ID', 207 | 'kind' => 'SCALAR' 208 | ] 209 | ] 210 | ] 211 | ] 212 | ] 213 | ]; 214 | 215 | $this->assertValidQuery($query, $expected); 216 | } 217 | 218 | public function testHasCorrectNodeRootField() { 219 | $query = '{ 220 | __schema { 221 | queryType { 222 | fields { 223 | name 224 | type { 225 | name 226 | kind 227 | } 228 | args { 229 | name 230 | type { 231 | kind 232 | ofType { 233 | name 234 | kind 235 | } 236 | } 237 | } 238 | } 239 | } 240 | } 241 | }'; 242 | 243 | $expected = [ 244 | '__schema' => [ 245 | 'queryType' => [ 246 | 'fields' => [ 247 | [ 248 | 'name' => 'node', 249 | 'type' => [ 250 | 'name' => 'Node', 251 | 'kind' => 'INTERFACE' 252 | ], 253 | 'args' => [ 254 | [ 255 | 'name' => 'id', 256 | 'type' => [ 257 | 'kind' => 'NON_NULL', 258 | 'ofType' => [ 259 | 'name' => 'ID', 260 | 'kind' => 'SCALAR' 261 | ] 262 | ] 263 | ] 264 | ] 265 | ] 266 | ] 267 | ] 268 | ] 269 | ]; 270 | 271 | $this->assertValidQuery($query, $expected); 272 | } 273 | 274 | /** 275 | * Returns test schema 276 | * 277 | * @return Schema 278 | */ 279 | protected function getSchema() { 280 | return new Schema([ 281 | 'query' => $this->getQueryType(), 282 | 283 | // We have to pass the types here manually because graphql-php cannot 284 | // recognize types that are only available through interfaces 285 | // https://github.com/webonyx/graphql-php/issues/38 286 | 'types' => [ 287 | self::$userType, 288 | self::$photoType 289 | ] 290 | ]); 291 | } 292 | 293 | /** 294 | * Returns test query type 295 | * 296 | * @return ObjectType 297 | */ 298 | protected function getQueryType() { 299 | $nodeField = $this->getNodeDefinitions(); 300 | return new ObjectType([ 301 | 'name' => 'Query', 302 | 'fields' => [ 303 | 'node' => $nodeField['nodeField'] 304 | ] 305 | ]); 306 | } 307 | 308 | /** 309 | * Returns node definitions 310 | * 311 | * @return array 312 | */ 313 | protected function getNodeDefinitions() { 314 | if (!self::$nodeDefinition){ 315 | self::$nodeDefinition = Node::nodeDefinitions( 316 | function($id, $context, ResolveInfo $info) { 317 | $userData = $this->getUserData(); 318 | if (array_key_exists($id, $userData)){ 319 | return $userData[$id]; 320 | } else { 321 | $photoData = $this->getPhotoData(); 322 | if (array_key_exists($id, $photoData)){ 323 | return $photoData[$id]; 324 | } 325 | } 326 | }, 327 | function($obj) { 328 | if (array_key_exists($obj['id'], $this->getUserData())){ 329 | return self::$userType; 330 | } else { 331 | return self::$photoType; 332 | } 333 | } 334 | ); 335 | 336 | self::$userType = new ObjectType([ 337 | 'name' => 'User', 338 | 'fields' => [ 339 | 'id' => [ 340 | 'type' => Type::nonNull(Type::id()), 341 | ], 342 | 'name' => [ 343 | 'type' => Type::string() 344 | ] 345 | ], 346 | 'interfaces' => [self::$nodeDefinition['nodeInterface']] 347 | ]); 348 | 349 | self::$photoType = new ObjectType([ 350 | 'name' => 'Photo', 351 | 'fields' => [ 352 | 'id' => [ 353 | 'type' => Type::nonNull(Type::id()) 354 | ], 355 | 'width' => [ 356 | 'type' => Type::int() 357 | ] 358 | ], 359 | 'interfaces' => [self::$nodeDefinition['nodeInterface']] 360 | ]); 361 | } 362 | return self::$nodeDefinition; 363 | } 364 | 365 | /** 366 | * Returns photo data 367 | * 368 | * @return array 369 | */ 370 | protected function getPhotoData() { 371 | return [ 372 | '3' => [ 373 | 'id' => 3, 374 | 'width' => 300 375 | ], 376 | '4' => [ 377 | 'id' => 4, 378 | 'width' => 400 379 | ] 380 | ]; 381 | } 382 | 383 | /** 384 | * Returns user data 385 | * 386 | * @return array 387 | */ 388 | protected function getUserData() { 389 | return [ 390 | '1' => [ 391 | 'id' => 1, 392 | 'name' => 'John Doe' 393 | ], 394 | '2' => [ 395 | 'id' => 2, 396 | 'name' => 'Jane Smith' 397 | ] 398 | ]; 399 | } 400 | 401 | /** 402 | * Helper function to test a query and the expected response. 403 | */ 404 | private function assertValidQuery($query, $expected) 405 | { 406 | $result = GraphQL::executeQuery($this->getSchema(), $query)->toArray(); 407 | 408 | $this->assertEquals(['data' => $expected], $result); 409 | } 410 | 411 | } -------------------------------------------------------------------------------- /tests/Node/PluralTest.php: -------------------------------------------------------------------------------- 1 | 'User', 23 | 'fields' => function() { 24 | return [ 25 | 'username' => [ 26 | 'type' => Type::string() 27 | ], 28 | 'url' => [ 29 | 'type' => Type::string() 30 | ] 31 | ]; 32 | } 33 | ]); 34 | 35 | $queryType = new ObjectType([ 36 | 'name' => 'Query', 37 | 'fields' => function() use ($userType) { 38 | return [ 39 | 'usernames' => Plural::pluralIdentifyingRootField([ 40 | 'argName' => 'usernames', 41 | 'description' => 'Map from a username to the user', 42 | 'inputType' => Type::string(), 43 | 'outputType' => $userType, 44 | 'resolveSingleInput' => function ($userName, $context, $info) { 45 | return [ 46 | 'username' => $userName, 47 | 'url' => 'www.facebook.com/' . $userName . '?lang=' . $info->rootValue['lang'] 48 | ]; 49 | } 50 | ]) 51 | ]; 52 | } 53 | ]); 54 | 55 | return new Schema([ 56 | 'query' => $queryType 57 | ]); 58 | } 59 | 60 | public function testAllowsFetching() { 61 | $query = '{ 62 | usernames(usernames:["dschafer", "leebyron", "schrockn"]) { 63 | username 64 | url 65 | } 66 | }'; 67 | 68 | $expected = array ( 69 | 'usernames' => 70 | array ( 71 | 0 => 72 | array ( 73 | 'username' => 'dschafer', 74 | 'url' => 'www.facebook.com/dschafer?lang=en', 75 | ), 76 | 1 => 77 | array ( 78 | 'username' => 'leebyron', 79 | 'url' => 'www.facebook.com/leebyron?lang=en', 80 | ), 81 | 2 => 82 | array ( 83 | 'username' => 'schrockn', 84 | 'url' => 'www.facebook.com/schrockn?lang=en', 85 | ), 86 | ), 87 | ); 88 | 89 | $this->assertValidQuery($query, $expected); 90 | } 91 | 92 | public function testCorrectlyIntrospects() 93 | { 94 | $query = '{ 95 | __schema { 96 | queryType { 97 | fields { 98 | name 99 | args { 100 | name 101 | type { 102 | kind 103 | ofType { 104 | kind 105 | ofType { 106 | kind 107 | ofType { 108 | name 109 | kind 110 | } 111 | } 112 | } 113 | } 114 | } 115 | type { 116 | kind 117 | ofType { 118 | name 119 | kind 120 | } 121 | } 122 | } 123 | } 124 | } 125 | }'; 126 | $expected = array ( 127 | '__schema' => 128 | array ( 129 | 'queryType' => 130 | array ( 131 | 'fields' => 132 | array ( 133 | 0 => 134 | array ( 135 | 'name' => 'usernames', 136 | 'args' => 137 | array ( 138 | 0 => 139 | array ( 140 | 'name' => 'usernames', 141 | 'type' => 142 | array ( 143 | 'kind' => 'NON_NULL', 144 | 'ofType' => 145 | array ( 146 | 'kind' => 'LIST', 147 | 'ofType' => 148 | array ( 149 | 'kind' => 'NON_NULL', 150 | 'ofType' => 151 | array ( 152 | 'name' => 'String', 153 | 'kind' => 'SCALAR', 154 | ), 155 | ), 156 | ), 157 | ), 158 | ), 159 | ), 160 | 'type' => 161 | array ( 162 | 'kind' => 'LIST', 163 | 'ofType' => 164 | array ( 165 | 'name' => 'User', 166 | 'kind' => 'OBJECT', 167 | ), 168 | ), 169 | ), 170 | ), 171 | ), 172 | ), 173 | ); 174 | 175 | $this->assertValidQuery($query, $expected); 176 | } 177 | 178 | /** 179 | * Helper function to test a query and the expected response. 180 | */ 181 | private function assertValidQuery($query, $expected) 182 | { 183 | $result = GraphQL::executeQuery($this->getSchema(), $query, ['lang' => 'en'])->toArray(); 184 | $this->assertEquals(['data' => $expected], $result); 185 | } 186 | } -------------------------------------------------------------------------------- /tests/RelayTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 20 | Connection::forwardConnectionArgs(), 21 | Relay::forwardConnectionArgs() 22 | ); 23 | } 24 | 25 | public function testBackwardConnectionArgs() 26 | { 27 | $this->assertEquals( 28 | Connection::backwardConnectionArgs(), 29 | Relay::backwardConnectionArgs() 30 | ); 31 | } 32 | 33 | public function testConnectionArgs() 34 | { 35 | $this->assertEquals( 36 | Connection::connectionArgs(), 37 | Relay::connectionArgs() 38 | ); 39 | } 40 | 41 | public function testConnectionDefinitions() 42 | { 43 | $nodeType = new ObjectType(['name' => 'test']); 44 | $config = ['nodeType' => $nodeType]; 45 | 46 | $this->assertEquals( 47 | Connection::connectionDefinitions($config), 48 | Relay::connectionDefinitions($config) 49 | ); 50 | } 51 | 52 | public function testConnectionType() 53 | { 54 | $nodeType = new ObjectType(['name' => 'test']); 55 | $config = ['nodeType' => $nodeType]; 56 | 57 | $this->assertEquals( 58 | Connection::createConnectionType($config), 59 | Relay::connectionType($config) 60 | ); 61 | } 62 | 63 | public function testEdgeType() 64 | { 65 | $nodeType = new ObjectType(['name' => 'test']); 66 | $config = ['nodeType' => $nodeType]; 67 | 68 | $this->assertEquals( 69 | Connection::createEdgeType($config), 70 | Relay::edgeType($config) 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/StarWarsConnectionTest.php: -------------------------------------------------------------------------------- 1 | 33 | array ( 34 | 'name' => 'Alliance to Restore the Republic', 35 | 'ships' => 36 | array ( 37 | 'edges' => 38 | array ( 39 | 0 => 40 | array ( 41 | 'node' => 42 | array ( 43 | 'name' => 'X-Wing', 44 | ), 45 | ), 46 | ), 47 | ), 48 | ), 49 | ); 50 | 51 | $this->assertValidQuery($query, $expected); 52 | } 53 | 54 | public function testFetchesTheFirstTwoShipsOfTheRebelsWithACursor() 55 | { 56 | $query = 'query MoreRebelShipsQuery { 57 | rebels { 58 | name, 59 | ships(first: 2) { 60 | edges { 61 | cursor, 62 | node { 63 | name 64 | } 65 | } 66 | } 67 | } 68 | }'; 69 | 70 | $expected = array ( 71 | 'rebels' => 72 | array ( 73 | 'name' => 'Alliance to Restore the Republic', 74 | 'ships' => 75 | array ( 76 | 'edges' => 77 | array ( 78 | 0 => 79 | array ( 80 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjA=', 81 | 'node' => 82 | array ( 83 | 'name' => 'X-Wing', 84 | ), 85 | ), 86 | 1 => 87 | array ( 88 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjE=', 89 | 'node' => 90 | array ( 91 | 'name' => 'Y-Wing', 92 | ), 93 | ), 94 | ), 95 | ), 96 | ), 97 | ); 98 | 99 | $this->assertValidQuery($query, $expected); 100 | } 101 | 102 | public function testFetchesTheNextThreeShipsOfTHeRebelsWithACursor() 103 | { 104 | $query = 'query EndOfRebelShipsQuery { 105 | rebels { 106 | name, 107 | ships(first: 3 after: "YXJyYXljb25uZWN0aW9uOjE=") { 108 | edges { 109 | cursor, 110 | node { 111 | name 112 | } 113 | } 114 | } 115 | } 116 | }'; 117 | 118 | $expected = array ( 119 | 'rebels' => 120 | array ( 121 | 'name' => 'Alliance to Restore the Republic', 122 | 'ships' => 123 | array ( 124 | 'edges' => 125 | array ( 126 | 0 => 127 | array ( 128 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjI=', 129 | 'node' => 130 | array ( 131 | 'name' => 'A-Wing', 132 | ), 133 | ), 134 | 1 => 135 | array ( 136 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjM=', 137 | 'node' => 138 | array ( 139 | 'name' => 'Millennium Falcon', 140 | ), 141 | ), 142 | 2 => 143 | array ( 144 | 'cursor' => 'YXJyYXljb25uZWN0aW9uOjQ=', 145 | 'node' => 146 | array ( 147 | 'name' => 'Home One', 148 | ), 149 | ), 150 | ), 151 | ), 152 | ), 153 | ); 154 | 155 | $this->assertValidQuery($query, $expected); 156 | } 157 | 158 | public function testFetchesNoShipsOfTheRebelsAtTheEndOfConnection() 159 | { 160 | $query = 'query RebelsQuery { 161 | rebels { 162 | name, 163 | ships(first: 3 after: "YXJyYXljb25uZWN0aW9uOjQ=") { 164 | edges { 165 | cursor, 166 | node { 167 | name 168 | } 169 | } 170 | } 171 | } 172 | }'; 173 | 174 | $expected = array ( 175 | 'rebels' => 176 | array ( 177 | 'name' => 'Alliance to Restore the Republic', 178 | 'ships' => 179 | array ( 180 | 'edges' => 181 | array ( 182 | ), 183 | ), 184 | ), 185 | ); 186 | 187 | $this->assertValidQuery($query, $expected); 188 | } 189 | 190 | public function testIdentifiesTheEndOfTheList() 191 | { 192 | $query = 'query EndOfRebelShipsQuery { 193 | rebels { 194 | name, 195 | originalShips: ships(first: 2) { 196 | edges { 197 | node { 198 | name 199 | } 200 | } 201 | pageInfo { 202 | hasNextPage 203 | } 204 | } 205 | moreShips: ships(first: 3 after: "YXJyYXljb25uZWN0aW9uOjE=") { 206 | edges { 207 | node { 208 | name 209 | } 210 | } 211 | pageInfo { 212 | hasNextPage 213 | } 214 | } 215 | } 216 | }'; 217 | $expected = array ( 218 | 'rebels' => 219 | array ( 220 | 'name' => 'Alliance to Restore the Republic', 221 | 'originalShips' => 222 | array ( 223 | 'edges' => 224 | array ( 225 | 0 => 226 | array ( 227 | 'node' => 228 | array ( 229 | 'name' => 'X-Wing', 230 | ), 231 | ), 232 | 1 => 233 | array ( 234 | 'node' => 235 | array ( 236 | 'name' => 'Y-Wing', 237 | ), 238 | ), 239 | ), 240 | 'pageInfo' => 241 | array ( 242 | 'hasNextPage' => true, 243 | ), 244 | ), 245 | 'moreShips' => 246 | array ( 247 | 'edges' => 248 | array ( 249 | 0 => 250 | array ( 251 | 'node' => 252 | array ( 253 | 'name' => 'A-Wing', 254 | ), 255 | ), 256 | 1 => 257 | array ( 258 | 'node' => 259 | array ( 260 | 'name' => 'Millennium Falcon', 261 | ), 262 | ), 263 | 2 => 264 | array ( 265 | 'node' => 266 | array ( 267 | 'name' => 'Home One', 268 | ), 269 | ), 270 | ), 271 | 'pageInfo' => 272 | array ( 273 | 'hasNextPage' => false, 274 | ), 275 | ), 276 | ), 277 | ); 278 | 279 | $this->assertValidQuery($query, $expected); 280 | } 281 | 282 | /** 283 | * Helper function to test a query and the expected response. 284 | */ 285 | private function assertValidQuery($query, $expected) 286 | { 287 | $result = GraphQL::executeQuery(StarWarsSchema::getSchema(), $query)->toArray(); 288 | 289 | $this->assertEquals(['data' => $expected], $result); 290 | } 291 | } -------------------------------------------------------------------------------- /tests/StarWarsData.php: -------------------------------------------------------------------------------- 1 | '1', 14 | 'name' => 'X-Wing' 15 | ]; 16 | 17 | protected static $ywing = [ 18 | 'id' => '2', 19 | 'name' => 'Y-Wing' 20 | ]; 21 | 22 | protected static $awing = [ 23 | 'id' => '3', 24 | 'name' => 'A-Wing' 25 | ]; 26 | 27 | protected static $falcon = [ 28 | 'id' => '4', 29 | 'name' => 'Millennium Falcon' 30 | ]; 31 | 32 | protected static $homeOne = [ 33 | 'id' => '5', 34 | 'name' => 'Home One' 35 | ]; 36 | 37 | protected static $tieFighter = [ 38 | 'id' => '6', 39 | 'name' => 'TIE Fighter' 40 | ]; 41 | 42 | protected static $tieInterceptor = [ 43 | 'id' => '7', 44 | 'name' => 'TIE Interceptor' 45 | ]; 46 | 47 | protected static $executor = [ 48 | 'id' => '8', 49 | 'name' => 'TIE Interceptor' 50 | ]; 51 | 52 | protected static $rebels = [ 53 | 'id' => '1', 54 | 'name' => 'Alliance to Restore the Republic', 55 | 'ships' => ['1', '2', '3', '4', '5'] 56 | ]; 57 | 58 | protected static $empire = [ 59 | 'id' => '2', 60 | 'name' => 'Galactic Empire', 61 | 'ships' => ['6', '7', '8'] 62 | ]; 63 | 64 | protected static $nextShip = 9; 65 | 66 | protected static $data; 67 | 68 | /** 69 | * Returns the data object 70 | * 71 | * @return array $array 72 | */ 73 | protected static function getData() 74 | { 75 | if (self::$data === null) { 76 | self::$data = [ 77 | 'Faction' => [ 78 | '1' => self::$rebels, 79 | '2' => self::$empire 80 | ], 81 | 'Ship' => [ 82 | '1' => self::$xwing, 83 | '2' => self::$ywing, 84 | '3' => self::$awing, 85 | '4' => self::$falcon, 86 | '5' => self::$homeOne, 87 | '6' => self::$tieFighter, 88 | '7' => self::$tieInterceptor, 89 | '8' => self::$executor 90 | ] 91 | ]; 92 | } 93 | return self::$data; 94 | } 95 | 96 | /** 97 | * @param $shipName 98 | * @param $factionId 99 | * @return array 100 | */ 101 | public static function createShip($shipName, $factionId) 102 | { 103 | $data = self::getData(); 104 | 105 | $newShip = [ 106 | 'id' => (string) self::$nextShip++, 107 | 'name' => $shipName 108 | ]; 109 | $data['Ship'][$newShip['id']] = $newShip; 110 | $data['Faction'][$factionId]['ships'][] = $newShip['id']; 111 | 112 | // Save 113 | self::$data = $data; 114 | 115 | return $newShip; 116 | } 117 | 118 | public static function getShip($id) 119 | { 120 | $data = self::getData(); 121 | return $data['Ship'][$id]; 122 | } 123 | 124 | public static function getFaction($id) 125 | { 126 | $data = self::getData(); 127 | return $data['Faction'][$id]; 128 | } 129 | 130 | public static function getRebels() 131 | { 132 | return self::$rebels; 133 | } 134 | 135 | public static function getEmpire() 136 | { 137 | return self::$empire; 138 | } 139 | } -------------------------------------------------------------------------------- /tests/StarWarsMutationTest.php: -------------------------------------------------------------------------------- 1 | 33 | array ( 34 | 'shipName' => 'B-Wing', 35 | 'factionId' => '1', 36 | 'clientMutationId' => 'abcde', 37 | ), 38 | ); 39 | 40 | $expected = array ( 41 | 'introduceShip' => 42 | array ( 43 | 'ship' => 44 | array ( 45 | 'id' => 'U2hpcDo5', 46 | 'name' => 'B-Wing', 47 | ), 48 | 'faction' => 49 | array ( 50 | 'name' => 'Alliance to Restore the Republic', 51 | ), 52 | 'clientMutationId' => 'abcde', 53 | ), 54 | ); 55 | 56 | $result = GraphQL::executeQuery(StarWarsSchema::getSchema(), $mutation, null, null, $params)->toArray(); 57 | 58 | $this->assertEquals(['data' => $expected], $result); 59 | } 60 | } -------------------------------------------------------------------------------- /tests/StarWarsObjectIdentificationTest.php: -------------------------------------------------------------------------------- 1 | 27 | array ( 28 | 'id' => 'RmFjdGlvbjox', 29 | 'name' => 'Alliance to Restore the Republic', 30 | ), 31 | ); 32 | 33 | $this->assertValidQuery($query, $expected); 34 | } 35 | 36 | public function testRefetchesTheRebels() 37 | { 38 | $query = 'query RebelsRefetchQuery { 39 | node(id: "RmFjdGlvbjox") { 40 | id 41 | ... on Faction { 42 | name 43 | } 44 | } 45 | }'; 46 | 47 | $expected = array ( 48 | 'node' => 49 | array ( 50 | 'id' => 'RmFjdGlvbjox', 51 | 'name' => 'Alliance to Restore the Republic', 52 | ), 53 | ); 54 | 55 | $this->assertValidQuery($query, $expected); 56 | } 57 | 58 | public function testFetchesTheIDAndNameOfTheEmpire() 59 | { 60 | $query = 'query EmpireQuery { 61 | empire { 62 | id 63 | name 64 | } 65 | }'; 66 | 67 | $expected = array ( 68 | 'empire' => 69 | array ( 70 | 'id' => 'RmFjdGlvbjoy', 71 | 'name' => 'Galactic Empire', 72 | ), 73 | ); 74 | 75 | $this->assertValidQuery($query, $expected); 76 | } 77 | 78 | public function testRefetchesTheEmpire() 79 | { 80 | $query = 'query EmpireRefetchQuery { 81 | node(id: "RmFjdGlvbjoy") { 82 | id 83 | ... on Faction { 84 | name 85 | } 86 | } 87 | }'; 88 | 89 | $expected = array ( 90 | 'node' => 91 | array ( 92 | 'id' => 'RmFjdGlvbjoy', 93 | 'name' => 'Galactic Empire', 94 | ), 95 | ); 96 | 97 | $this->assertValidQuery($query, $expected); 98 | } 99 | 100 | public function testRefetchesTheXWing() 101 | { 102 | $query = 'query XWingRefetchQuery { 103 | node(id: "U2hpcDox") { 104 | id 105 | ... on Ship { 106 | name 107 | } 108 | } 109 | }'; 110 | 111 | $expected = array ( 112 | 'node' => 113 | array ( 114 | 'id' => 'U2hpcDox', 115 | 'name' => 'X-Wing', 116 | ), 117 | ); 118 | 119 | $this->assertValidQuery($query, $expected); 120 | } 121 | 122 | /** 123 | * Helper function to test a query and the expected response. 124 | */ 125 | private function assertValidQuery($query, $expected) 126 | { 127 | $result = GraphQL::executeQuery(StarWarsSchema::getSchema(), $query)->toArray(); 128 | 129 | $this->assertEquals(['data' => $expected], $result); 130 | } 131 | } -------------------------------------------------------------------------------- /tests/StarWarsSchema.php: -------------------------------------------------------------------------------- 1 | 'Ship', 148 | 'description' => 'A ship in the Star Wars saga', 149 | 'fields' => function() { 150 | return [ 151 | 'id' => Relay::globalIdField(), 152 | 'name' => [ 153 | 'type' => Type::string(), 154 | 'description' => 'The name of the ship.' 155 | ] 156 | ]; 157 | }, 158 | 'interfaces' => [$nodeDefinition['nodeInterface']] 159 | ]); 160 | self::$shipType = $shipType; 161 | } 162 | return self::$shipType; 163 | } 164 | 165 | /** 166 | * We define our faction type, which implements the node interface. 167 | * 168 | * This implements the following type system shorthand: 169 | * type Faction : Node { 170 | * id: String! 171 | * name: String 172 | * ships: ShipConnection 173 | * } 174 | * 175 | * @return ObjectType 176 | */ 177 | protected static function getFactionType() 178 | { 179 | if (self::$factionType === null){ 180 | $shipConnection = self::getShipConnection(); 181 | $nodeDefinition = self::getNodeDefinition(); 182 | 183 | $factionType = new ObjectType([ 184 | 'name' => 'Faction', 185 | 'description' => 'A faction in the Star Wars saga', 186 | 'fields' => function() use ($shipConnection) { 187 | return [ 188 | 'id' => Relay::globalIdField(), 189 | 'name' => [ 190 | 'type' => Type::string(), 191 | 'description' => 'The name of the faction.' 192 | ], 193 | 'ships' => [ 194 | 'type' => $shipConnection['connectionType'], 195 | 'description' => 'The ships used by the faction.', 196 | 'args' => Relay::connectionArgs(), 197 | 'resolve' => function($faction, $args) { 198 | // Map IDs from faction back to ships 199 | $data = array_map(function($id) { 200 | return StarWarsData::getShip($id); 201 | }, $faction['ships']); 202 | return Relay::connectionFromArray($data, $args); 203 | } 204 | ] 205 | ]; 206 | }, 207 | 'interfaces' => [$nodeDefinition['nodeInterface']] 208 | ]); 209 | 210 | self::$factionType = $factionType; 211 | } 212 | 213 | return self::$factionType; 214 | } 215 | 216 | /** 217 | * We define a connection between a faction and its ships. 218 | * 219 | * connectionType implements the following type system shorthand: 220 | * type ShipConnection { 221 | * edges: [ShipEdge] 222 | * pageInfo: PageInfo! 223 | * } 224 | * 225 | * connectionType has an edges field - a list of edgeTypes that implement the 226 | * following type system shorthand: 227 | * type ShipEdge { 228 | * cursor: String! 229 | * node: Ship 230 | * } 231 | */ 232 | protected static function getShipConnection() 233 | { 234 | if (self::$shipConnection === null){ 235 | $shipType = self::getShipType(); 236 | $shipConnection = Relay::connectionDefinitions([ 237 | 'nodeType' => $shipType 238 | ]); 239 | 240 | self::$shipConnection = $shipConnection; 241 | } 242 | 243 | return self::$shipConnection; 244 | } 245 | 246 | /** 247 | * This will return a GraphQLFieldConfig for our ship 248 | * mutation. 249 | * 250 | * It creates these two types implicitly: 251 | * input IntroduceShipInput { 252 | * clientMutationId: string! 253 | * shipName: string! 254 | * factionId: ID! 255 | * } 256 | * 257 | * input IntroduceShipPayload { 258 | * clientMutationId: string! 259 | * ship: Ship 260 | * faction: Faction 261 | * } 262 | */ 263 | public static function getShipMutation() 264 | { 265 | if (self::$shipMutation === null){ 266 | $shipType = self::getShipType(); 267 | $factionType = self::getFactionType(); 268 | 269 | $shipMutation = Relay::mutationWithClientMutationId([ 270 | 'name' => 'IntroduceShip', 271 | 'inputFields' => [ 272 | 'shipName' => [ 273 | 'type' => Type::nonNull(Type::string()) 274 | ], 275 | 'factionId' => [ 276 | 'type' => Type::nonNull(Type::id()) 277 | ] 278 | ], 279 | 'outputFields' => [ 280 | 'ship' => [ 281 | 'type' => $shipType, 282 | 'resolve' => function ($payload) { 283 | return StarWarsData::getShip($payload['shipId']); 284 | } 285 | ], 286 | 'faction' => [ 287 | 'type' => $factionType, 288 | 'resolve' => function ($payload) { 289 | return StarWarsData::getFaction($payload['factionId']); 290 | } 291 | ] 292 | ], 293 | 'mutateAndGetPayload' => function ($input) { 294 | $newShip = StarWarsData::createShip($input['shipName'], $input['factionId']); 295 | return [ 296 | 'shipId' => $newShip['id'], 297 | 'factionId' => $input['factionId'] 298 | ]; 299 | } 300 | ]); 301 | self::$shipMutation = $shipMutation; 302 | } 303 | 304 | return self::$shipMutation; 305 | } 306 | 307 | /** 308 | * Returns the complete schema for StarWars tests 309 | * 310 | * @return Schema 311 | */ 312 | public static function getSchema() 313 | { 314 | $factionType = self::getFactionType(); 315 | $nodeDefinition = self::getNodeDefinition(); 316 | $shipMutation = self::getShipMutation(); 317 | 318 | /** 319 | * This is the type that will be the root of our query, and the 320 | * entry point into our schema. 321 | * 322 | * This implements the following type system shorthand: 323 | * type Query { 324 | * rebels: Faction 325 | * empire: Faction 326 | * node(id: String!): Node 327 | * } 328 | */ 329 | $queryType = new ObjectType([ 330 | 'name' => 'Query', 331 | 'fields' => function () use ($factionType, $nodeDefinition) { 332 | return [ 333 | 'rebels' => [ 334 | 'type' => $factionType, 335 | 'resolve' => function (){ 336 | return StarWarsData::getRebels(); 337 | } 338 | ], 339 | 'empire' => [ 340 | 'type' => $factionType, 341 | 'resolve' => function () { 342 | return StarWarsData::getEmpire(); 343 | } 344 | ], 345 | 'node' => $nodeDefinition['nodeField'] 346 | ]; 347 | }, 348 | ]); 349 | 350 | /** 351 | * This is the type that will be the root of our mutations, and the 352 | * entry point into performing writes in our schema. 353 | * 354 | * This implements the following type system shorthand: 355 | * type Mutation { 356 | * introduceShip(input IntroduceShipInput!): IntroduceShipPayload 357 | * } 358 | */ 359 | $mutationType = new ObjectType([ 360 | 'name' => 'Mutation', 361 | 'fields' => function () use ($shipMutation) { 362 | return [ 363 | 'introduceShip' => $shipMutation 364 | ]; 365 | } 366 | ]); 367 | 368 | /** 369 | * Finally, we construct our schema (whose starting query type is the query 370 | * type we defined above) and export it. 371 | */ 372 | $schema = new Schema([ 373 | 'query' => $queryType, 374 | 'mutation' => $mutationType 375 | ]); 376 | 377 | return $schema; 378 | } 379 | } --------------------------------------------------------------------------------