diff --git a/src/Folklore/GraphQL/Relay/ConnectionEdgeType.php b/src/Folklore/GraphQL/Relay/ConnectionEdgeType.php new file mode 100644 index 00000000..70eb0bc3 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/ConnectionEdgeType.php @@ -0,0 +1,26 @@ + [ + 'type' => Type::nonNull(Type::id()) + ], + 'node' => [ + 'type' => app('graphql')->type('Node') + ] + ]; + } + + public function toType() + { + return new EdgeObjectType($this->toArray()); + } +} diff --git a/src/Folklore/GraphQL/Relay/Console/ConnectionMakeCommand.php b/src/Folklore/GraphQL/Relay/Console/ConnectionMakeCommand.php new file mode 100644 index 00000000..eacb844b --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Console/ConnectionMakeCommand.php @@ -0,0 +1,82 @@ +replaceType($stub, $name); + } + + /** + * Replace the namespace for the given stub. + * + * @param string $stub + * @param string $name + * @return $this + */ + protected function replaceType($stub, $name) + { + preg_match('/([^\\\]+)$/', $name, $matches); + $stub = str_replace( + 'DummyConnection', + $matches[1], + $stub + ); + + return $stub; + } +} diff --git a/src/Folklore/GraphQL/Relay/Console/InputMakeCommand.php b/src/Folklore/GraphQL/Relay/Console/InputMakeCommand.php new file mode 100644 index 00000000..00ac452b --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Console/InputMakeCommand.php @@ -0,0 +1,82 @@ +replaceType($stub, $name); + } + + /** + * Replace the namespace for the given stub. + * + * @param string $stub + * @param string $name + * @return $this + */ + protected function replaceType($stub, $name) + { + preg_match('/([^\\\]+)$/', $name, $matches); + $stub = str_replace( + 'DummyInput', + $matches[1], + $stub + ); + + return $stub; + } +} diff --git a/src/Folklore/GraphQL/Relay/Console/MutationMakeCommand.php b/src/Folklore/GraphQL/Relay/Console/MutationMakeCommand.php new file mode 100644 index 00000000..8d55d822 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Console/MutationMakeCommand.php @@ -0,0 +1,82 @@ +replaceType($stub, $name); + } + + /** + * Replace the namespace for the given stub. + * + * @param string $stub + * @param string $name + * @return $this + */ + protected function replaceType($stub, $name) + { + preg_match('/([^\\\]+)$/', $name, $matches); + $stub = str_replace( + 'DummyMutation', + $matches[1], + $stub + ); + + return $stub; + } +} diff --git a/src/Folklore/GraphQL/Relay/Console/NodeMakeCommand.php b/src/Folklore/GraphQL/Relay/Console/NodeMakeCommand.php new file mode 100644 index 00000000..98b14a9b --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Console/NodeMakeCommand.php @@ -0,0 +1,82 @@ +replaceType($stub, $name); + } + + /** + * Replace the namespace for the given stub. + * + * @param string $stub + * @param string $name + * @return $this + */ + protected function replaceType($stub, $name) + { + preg_match('/([^\\\]+)$/', $name, $matches); + $stub = str_replace( + 'DummyType', + $matches[1], + $stub + ); + + return $stub; + } +} diff --git a/src/Folklore/GraphQL/Relay/Console/PayloadMakeCommand.php b/src/Folklore/GraphQL/Relay/Console/PayloadMakeCommand.php new file mode 100644 index 00000000..52f8fc94 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Console/PayloadMakeCommand.php @@ -0,0 +1,82 @@ +replaceType($stub, $name); + } + + /** + * Replace the namespace for the given stub. + * + * @param string $stub + * @param string $name + * @return $this + */ + protected function replaceType($stub, $name) + { + preg_match('/([^\\\]+)$/', $name, $matches); + $stub = str_replace( + 'DummyPayload', + $matches[1], + $stub + ); + + return $stub; + } +} diff --git a/src/Folklore/GraphQL/Relay/Console/stubs/connection.stub b/src/Folklore/GraphQL/Relay/Console/stubs/connection.stub new file mode 100644 index 00000000..30664441 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Console/stubs/connection.stub @@ -0,0 +1,20 @@ + 'DummyConnection', + 'description' => 'A relay connection type' + ]; + + public function edgeType() + { + return null; + } +} diff --git a/src/Folklore/GraphQL/Relay/Console/stubs/input.stub b/src/Folklore/GraphQL/Relay/Console/stubs/input.stub new file mode 100644 index 00000000..0de8b90f --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Console/stubs/input.stub @@ -0,0 +1,22 @@ + 'DummyInput', + 'description' => 'A relay mutation input type' + ]; + + public function fields() + { + return [ + + ]; + } +} diff --git a/src/Folklore/GraphQL/Relay/Console/stubs/mutation.stub b/src/Folklore/GraphQL/Relay/Console/stubs/mutation.stub new file mode 100644 index 00000000..a0276078 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Console/stubs/mutation.stub @@ -0,0 +1,30 @@ + 'DummyMutation', + 'description' => 'A relay mutation' + ]; + + protected function inputType() + { + return null; + } + + public function type() + { + return null; + } + + public function resolve($root, $args, $context, ResolveInfo $info) + { + + } +} diff --git a/src/Folklore/GraphQL/Relay/Console/stubs/node.stub b/src/Folklore/GraphQL/Relay/Console/stubs/node.stub new file mode 100644 index 00000000..1dfce5c7 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Console/stubs/node.stub @@ -0,0 +1,29 @@ + 'DummyType', + 'description' => 'A relay node type' + ]; + + public function fields() + { + return [ + 'id' => [ + 'type' => Type::nonNull(Type::id()) + ] + ]; + } + + public function resolveById($id) + { + // Get a node from an id + } +} diff --git a/src/Folklore/GraphQL/Relay/Console/stubs/payload.stub b/src/Folklore/GraphQL/Relay/Console/stubs/payload.stub new file mode 100644 index 00000000..d98d7dc2 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Console/stubs/payload.stub @@ -0,0 +1,22 @@ + 'DummyPayload', + 'description' => 'A relay mutation payload type' + ]; + + public function fields() + { + return [ + + ]; + } +} diff --git a/src/Folklore/GraphQL/Relay/EdgeObjectType.php b/src/Folklore/GraphQL/Relay/EdgeObjectType.php new file mode 100644 index 00000000..a09a079a --- /dev/null +++ b/src/Folklore/GraphQL/Relay/EdgeObjectType.php @@ -0,0 +1,29 @@ +_fields = null; + $currentFields = array_get($this->config, 'fields'); + $fieldsResolver = function () use ($currentFields, $type) { + $fields = $currentFields instanceof Closure ? $currentFields():$currentFields; + array_set($fields, 'node.type', $type); + return $fields; + }; + array_set($this->config, 'fields', $fieldsResolver); + + return $this; + } + + public function getEdgeType() + { + return array_get($this->getField('node')->config, 'type'); + } +} diff --git a/src/Folklore/GraphQL/Relay/EdgesCollection.php b/src/Folklore/GraphQL/Relay/EdgesCollection.php new file mode 100644 index 00000000..ad4282b5 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/EdgesCollection.php @@ -0,0 +1,64 @@ +total = $total; + } + + public function getTotal() + { + return $this->total; + } + + public function setHasNextPage($hasNextPage) + { + $this->hasNextPage = $hasNextPage; + } + + public function getHasNextPage() + { + return $this->hasNextPage; + } + + public function setHasPreviousPage($hasPreviousPage) + { + $this->hasPreviousPage = $hasPreviousPage; + } + + public function getHasPreviousPage() + { + return $this->hasPreviousPage; + } + + public function setStartCursor($startCursor) + { + $this->startCursor = $startCursor; + } + + public function getStartCursor() + { + return $this->startCursor; + } + + public function setEndCursor($endCursor) + { + $this->endCursor = $endCursor; + } + + public function getEndCursor() + { + return $this->endCursor; + } +} diff --git a/src/Folklore/GraphQL/Relay/Exception/NodeInvalid.php b/src/Folklore/GraphQL/Relay/Exception/NodeInvalid.php new file mode 100644 index 00000000..ed6b41fa --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Exception/NodeInvalid.php @@ -0,0 +1,7 @@ +originalNode; + } + + public function setNode($node) + { + $this->originalNode = $node; + $this->attributes = $node instanceof Arrayable ? $node->toArray():(array)$node; + return $this; + } + + public function setClientMutationId($clientMutationId) + { + $this->clientMutationId = $clientMutationId; + return $this; + } + + public function getClientMutationId() + { + return $this->clientMutationId; + } +} diff --git a/src/Folklore/GraphQL/Relay/NodeIdField.php b/src/Folklore/GraphQL/Relay/NodeIdField.php new file mode 100644 index 00000000..aeda542b --- /dev/null +++ b/src/Folklore/GraphQL/Relay/NodeIdField.php @@ -0,0 +1,49 @@ + 'A relay node id field' + ]; + + public function type() + { + return Type::nonNull(Type::id()); + } + + public function setIdResolver($idResolver) + { + $this->idResolver = $idResolver; + return $this; + } + + public function getIdResolver() + { + return $this->idResolver; + } + + public function setIdType($idType) + { + $this->idType = $idType; + return $this; + } + + public function getIdType() + { + return $this->idType; + } + + public function resolve() + { + $id = call_user_func_array($this->idResolver, func_get_args()); + return app('graphql.relay')->toGlobalId($this->idType, $id); + } +} diff --git a/src/Folklore/GraphQL/Relay/NodeInterface.php b/src/Folklore/GraphQL/Relay/NodeInterface.php new file mode 100644 index 00000000..f602ee16 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/NodeInterface.php @@ -0,0 +1,32 @@ + 'Node', + 'description' => 'The relay node interface' + ]; + + public function fields() + { + return [ + 'id' => [ + 'type' => Type::nonNull(Type::id()) + ] + ]; + } + + protected function resolveType($root) + { + if (!$root instanceof NodeResponse) { + throw new NodeRootInvalid('$root is not a NodeResponse'); + } + return $root->getType(); + } +} diff --git a/src/Folklore/GraphQL/Relay/NodeQuery.php b/src/Folklore/GraphQL/Relay/NodeQuery.php new file mode 100644 index 00000000..cc769e04 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/NodeQuery.php @@ -0,0 +1,61 @@ + 'NodeQuery', + 'description' => 'A query' + ]; + + public function type() + { + return app('graphql')->type('Node'); + } + + public function args() + { + return [ + 'id' => [ + 'name' => 'id', + 'type' => Type::nonNull(Type::id()) + ] + ]; + } + + public function resolve($root, $args, $context, ResolveInfo $info) + { + $globalId = app('graphql.relay')->fromGlobalId($args['id']); + $typeName = $globalId['type']; + $id = $globalId['id']; + $types = app('graphql')->getTypes(); + $typeClass = array_get($types, $typeName); + + if (!$typeClass) { + throw new TypeNotFound('Type "'.$typeName.'" not found.'); + } + + $type = app($typeClass); + + if (!$type instanceof NodeContract) { + throw new NodeInvalid('Type "'.$typeName.'" doesn\'t implement the NodeContract interface.'); + } + + $node = $type->resolveById($id); + + $response = new NodeResponse(); + $response->setNode($node); + $response->setType(app('graphql')->type($typeName)); + + return $response; + } +} diff --git a/src/Folklore/GraphQL/Relay/NodeResponse.php b/src/Folklore/GraphQL/Relay/NodeResponse.php new file mode 100644 index 00000000..9b1147b3 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/NodeResponse.php @@ -0,0 +1,33 @@ +originalNode; + } + + public function setNode($node) + { + $this->originalNode = $node; + $this->attributes = $node instanceof Arrayable ? $node->toArray():(array)$node; + } + + public function setType($type) + { + $this->type = $type; + } + + public function getType() + { + return $this->type; + } +} diff --git a/src/Folklore/GraphQL/Relay/PageInfoType.php b/src/Folklore/GraphQL/Relay/PageInfoType.php new file mode 100644 index 00000000..9f490ed4 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/PageInfoType.php @@ -0,0 +1,33 @@ + 'PageInfo', + 'description' => 'The relay pageInfo type used by connections' + ]; + + public function fields() + { + return [ + 'hasNextPage' => [ + 'type' => Type::nonNull(Type::boolean()) + ], + 'hasPreviousPage' => [ + 'type' => Type::nonNull(Type::boolean()) + ], + 'startCursor' => [ + 'type' => Type::string() + ], + 'endCursor' => [ + 'type' => Type::string() + ] + ]; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay.php b/src/Folklore/GraphQL/Relay/Relay.php new file mode 100644 index 00000000..d720334e --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay.php @@ -0,0 +1,74 @@ +app = $app; + $this->graphql = $app['graphql']; + } + + public function connectionField($config = []) + { + $field = new ConnectionField($config); + return $field; + } + + public function connectionFieldFromEdgeType($edgeType, $config = []) + { + $typeName = array_get($edgeType->config, 'name'); + $connectionName = array_get($config, 'connectionTypeName', str_plural($typeName).'Connection'); + + $connectionType = new ConnectionType([ + 'name' => $connectionName + ]); + $connectionType->setEdgeType($edgeType); + $this->graphql->addType($connectionType, $connectionName); + + $fieldConfig = array_except($config, ['connectionTypeName']); + $field = new ConnectionField($fieldConfig); + $field->setType($this->graphql->type($connectionName)); + return $field; + } + + public function connectionFieldFromEdgeTypeAndQueryBuilder($edgeType, $queryBuilderResolver, $config = []) + { + $field = $this->connectionFieldFromEdgeType($edgeType, $config); + $field->setQueryBuilderResolver($queryBuilderResolver); + return $field; + } + + public function toGlobalId($type, $id) + { + return base64_encode($type.':'.$id); + } + + public function fromGlobalId($globalId) + { + $id = explode(':', base64_decode($globalId), 2); + return sizeof($id) === 2 ? [ + 'type' => $id[0], + 'id' => $id[1] + ]:null; + } + + public function getIdFromGlobalId($globalId) + { + $id = $this->fromGlobalId($globalId); + return $id ? $id['id']:null; + } + + public function getTypeFromGlobalId($globalId) + { + $id = $this->fromGlobalId($globalId); + return $id ? $id['type']:null; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/ConnectionEdgeType.php b/src/Folklore/GraphQL/Relay/Relay/ConnectionEdgeType.php new file mode 100644 index 00000000..70eb0bc3 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/ConnectionEdgeType.php @@ -0,0 +1,26 @@ + [ + 'type' => Type::nonNull(Type::id()) + ], + 'node' => [ + 'type' => app('graphql')->type('Node') + ] + ]; + } + + public function toType() + { + return new EdgeObjectType($this->toArray()); + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Console/ConnectionMakeCommand.php b/src/Folklore/GraphQL/Relay/Relay/Console/ConnectionMakeCommand.php new file mode 100644 index 00000000..eacb844b --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Console/ConnectionMakeCommand.php @@ -0,0 +1,82 @@ +replaceType($stub, $name); + } + + /** + * Replace the namespace for the given stub. + * + * @param string $stub + * @param string $name + * @return $this + */ + protected function replaceType($stub, $name) + { + preg_match('/([^\\\]+)$/', $name, $matches); + $stub = str_replace( + 'DummyConnection', + $matches[1], + $stub + ); + + return $stub; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Console/InputMakeCommand.php b/src/Folklore/GraphQL/Relay/Relay/Console/InputMakeCommand.php new file mode 100644 index 00000000..00ac452b --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Console/InputMakeCommand.php @@ -0,0 +1,82 @@ +replaceType($stub, $name); + } + + /** + * Replace the namespace for the given stub. + * + * @param string $stub + * @param string $name + * @return $this + */ + protected function replaceType($stub, $name) + { + preg_match('/([^\\\]+)$/', $name, $matches); + $stub = str_replace( + 'DummyInput', + $matches[1], + $stub + ); + + return $stub; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Console/MutationMakeCommand.php b/src/Folklore/GraphQL/Relay/Relay/Console/MutationMakeCommand.php new file mode 100644 index 00000000..8d55d822 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Console/MutationMakeCommand.php @@ -0,0 +1,82 @@ +replaceType($stub, $name); + } + + /** + * Replace the namespace for the given stub. + * + * @param string $stub + * @param string $name + * @return $this + */ + protected function replaceType($stub, $name) + { + preg_match('/([^\\\]+)$/', $name, $matches); + $stub = str_replace( + 'DummyMutation', + $matches[1], + $stub + ); + + return $stub; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Console/NodeMakeCommand.php b/src/Folklore/GraphQL/Relay/Relay/Console/NodeMakeCommand.php new file mode 100644 index 00000000..98b14a9b --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Console/NodeMakeCommand.php @@ -0,0 +1,82 @@ +replaceType($stub, $name); + } + + /** + * Replace the namespace for the given stub. + * + * @param string $stub + * @param string $name + * @return $this + */ + protected function replaceType($stub, $name) + { + preg_match('/([^\\\]+)$/', $name, $matches); + $stub = str_replace( + 'DummyType', + $matches[1], + $stub + ); + + return $stub; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Console/PayloadMakeCommand.php b/src/Folklore/GraphQL/Relay/Relay/Console/PayloadMakeCommand.php new file mode 100644 index 00000000..52f8fc94 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Console/PayloadMakeCommand.php @@ -0,0 +1,82 @@ +replaceType($stub, $name); + } + + /** + * Replace the namespace for the given stub. + * + * @param string $stub + * @param string $name + * @return $this + */ + protected function replaceType($stub, $name) + { + preg_match('/([^\\\]+)$/', $name, $matches); + $stub = str_replace( + 'DummyPayload', + $matches[1], + $stub + ); + + return $stub; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Console/stubs/connection.stub b/src/Folklore/GraphQL/Relay/Relay/Console/stubs/connection.stub new file mode 100644 index 00000000..67c261da --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Console/stubs/connection.stub @@ -0,0 +1,20 @@ + 'DummyConnection', + 'description' => 'A relay connection type' + ]; + + protected function edgeType() + { + return null; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Console/stubs/input.stub b/src/Folklore/GraphQL/Relay/Relay/Console/stubs/input.stub new file mode 100644 index 00000000..0de8b90f --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Console/stubs/input.stub @@ -0,0 +1,22 @@ + 'DummyInput', + 'description' => 'A relay mutation input type' + ]; + + public function fields() + { + return [ + + ]; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Console/stubs/mutation.stub b/src/Folklore/GraphQL/Relay/Relay/Console/stubs/mutation.stub new file mode 100644 index 00000000..a0276078 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Console/stubs/mutation.stub @@ -0,0 +1,30 @@ + 'DummyMutation', + 'description' => 'A relay mutation' + ]; + + protected function inputType() + { + return null; + } + + public function type() + { + return null; + } + + public function resolve($root, $args, $context, ResolveInfo $info) + { + + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Console/stubs/node.stub b/src/Folklore/GraphQL/Relay/Relay/Console/stubs/node.stub new file mode 100644 index 00000000..1dfce5c7 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Console/stubs/node.stub @@ -0,0 +1,29 @@ + 'DummyType', + 'description' => 'A relay node type' + ]; + + public function fields() + { + return [ + 'id' => [ + 'type' => Type::nonNull(Type::id()) + ] + ]; + } + + public function resolveById($id) + { + // Get a node from an id + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Console/stubs/payload.stub b/src/Folklore/GraphQL/Relay/Relay/Console/stubs/payload.stub new file mode 100644 index 00000000..d98d7dc2 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Console/stubs/payload.stub @@ -0,0 +1,22 @@ + 'DummyPayload', + 'description' => 'A relay mutation payload type' + ]; + + public function fields() + { + return [ + + ]; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/EdgeObjectType.php b/src/Folklore/GraphQL/Relay/Relay/EdgeObjectType.php new file mode 100644 index 00000000..a09a079a --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/EdgeObjectType.php @@ -0,0 +1,29 @@ +_fields = null; + $currentFields = array_get($this->config, 'fields'); + $fieldsResolver = function () use ($currentFields, $type) { + $fields = $currentFields instanceof Closure ? $currentFields():$currentFields; + array_set($fields, 'node.type', $type); + return $fields; + }; + array_set($this->config, 'fields', $fieldsResolver); + + return $this; + } + + public function getEdgeType() + { + return array_get($this->getField('node')->config, 'type'); + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/EdgesCollection.php b/src/Folklore/GraphQL/Relay/Relay/EdgesCollection.php new file mode 100644 index 00000000..ad4282b5 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/EdgesCollection.php @@ -0,0 +1,64 @@ +total = $total; + } + + public function getTotal() + { + return $this->total; + } + + public function setHasNextPage($hasNextPage) + { + $this->hasNextPage = $hasNextPage; + } + + public function getHasNextPage() + { + return $this->hasNextPage; + } + + public function setHasPreviousPage($hasPreviousPage) + { + $this->hasPreviousPage = $hasPreviousPage; + } + + public function getHasPreviousPage() + { + return $this->hasPreviousPage; + } + + public function setStartCursor($startCursor) + { + $this->startCursor = $startCursor; + } + + public function getStartCursor() + { + return $this->startCursor; + } + + public function setEndCursor($endCursor) + { + $this->endCursor = $endCursor; + } + + public function getEndCursor() + { + return $this->endCursor; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Exception/NodeInvalid.php b/src/Folklore/GraphQL/Relay/Relay/Exception/NodeInvalid.php new file mode 100644 index 00000000..ed6b41fa --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Exception/NodeInvalid.php @@ -0,0 +1,7 @@ +originalNode; + } + + public function setNode($node) + { + $this->originalNode = $node; + $this->attributes = $node instanceof Arrayable ? $node->toArray():(array)$node; + return $this; + } + + public function setClientMutationId($clientMutationId) + { + $this->clientMutationId = $clientMutationId; + return $this; + } + + public function getClientMutationId() + { + return $this->clientMutationId; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/NodeIdField.php b/src/Folklore/GraphQL/Relay/Relay/NodeIdField.php new file mode 100644 index 00000000..aeda542b --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/NodeIdField.php @@ -0,0 +1,49 @@ + 'A relay node id field' + ]; + + public function type() + { + return Type::nonNull(Type::id()); + } + + public function setIdResolver($idResolver) + { + $this->idResolver = $idResolver; + return $this; + } + + public function getIdResolver() + { + return $this->idResolver; + } + + public function setIdType($idType) + { + $this->idType = $idType; + return $this; + } + + public function getIdType() + { + return $this->idType; + } + + public function resolve() + { + $id = call_user_func_array($this->idResolver, func_get_args()); + return app('graphql.relay')->toGlobalId($this->idType, $id); + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/NodeInterface.php b/src/Folklore/GraphQL/Relay/Relay/NodeInterface.php new file mode 100644 index 00000000..f602ee16 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/NodeInterface.php @@ -0,0 +1,32 @@ + 'Node', + 'description' => 'The relay node interface' + ]; + + public function fields() + { + return [ + 'id' => [ + 'type' => Type::nonNull(Type::id()) + ] + ]; + } + + protected function resolveType($root) + { + if (!$root instanceof NodeResponse) { + throw new NodeRootInvalid('$root is not a NodeResponse'); + } + return $root->getType(); + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/NodeQuery.php b/src/Folklore/GraphQL/Relay/Relay/NodeQuery.php new file mode 100644 index 00000000..cc769e04 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/NodeQuery.php @@ -0,0 +1,61 @@ + 'NodeQuery', + 'description' => 'A query' + ]; + + public function type() + { + return app('graphql')->type('Node'); + } + + public function args() + { + return [ + 'id' => [ + 'name' => 'id', + 'type' => Type::nonNull(Type::id()) + ] + ]; + } + + public function resolve($root, $args, $context, ResolveInfo $info) + { + $globalId = app('graphql.relay')->fromGlobalId($args['id']); + $typeName = $globalId['type']; + $id = $globalId['id']; + $types = app('graphql')->getTypes(); + $typeClass = array_get($types, $typeName); + + if (!$typeClass) { + throw new TypeNotFound('Type "'.$typeName.'" not found.'); + } + + $type = app($typeClass); + + if (!$type instanceof NodeContract) { + throw new NodeInvalid('Type "'.$typeName.'" doesn\'t implement the NodeContract interface.'); + } + + $node = $type->resolveById($id); + + $response = new NodeResponse(); + $response->setNode($node); + $response->setType(app('graphql')->type($typeName)); + + return $response; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/NodeResponse.php b/src/Folklore/GraphQL/Relay/Relay/NodeResponse.php new file mode 100644 index 00000000..9b1147b3 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/NodeResponse.php @@ -0,0 +1,33 @@ +originalNode; + } + + public function setNode($node) + { + $this->originalNode = $node; + $this->attributes = $node instanceof Arrayable ? $node->toArray():(array)$node; + } + + public function setType($type) + { + $this->type = $type; + } + + public function getType() + { + return $this->type; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/PageInfoType.php b/src/Folklore/GraphQL/Relay/Relay/PageInfoType.php new file mode 100644 index 00000000..9f490ed4 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/PageInfoType.php @@ -0,0 +1,33 @@ + 'PageInfo', + 'description' => 'The relay pageInfo type used by connections' + ]; + + public function fields() + { + return [ + 'hasNextPage' => [ + 'type' => Type::nonNull(Type::boolean()) + ], + 'hasPreviousPage' => [ + 'type' => Type::nonNull(Type::boolean()) + ], + 'startCursor' => [ + 'type' => Type::string() + ], + 'endCursor' => [ + 'type' => Type::string() + ] + ]; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Relay.php b/src/Folklore/GraphQL/Relay/Relay/Relay.php new file mode 100644 index 00000000..d720334e --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Relay.php @@ -0,0 +1,74 @@ +app = $app; + $this->graphql = $app['graphql']; + } + + public function connectionField($config = []) + { + $field = new ConnectionField($config); + return $field; + } + + public function connectionFieldFromEdgeType($edgeType, $config = []) + { + $typeName = array_get($edgeType->config, 'name'); + $connectionName = array_get($config, 'connectionTypeName', str_plural($typeName).'Connection'); + + $connectionType = new ConnectionType([ + 'name' => $connectionName + ]); + $connectionType->setEdgeType($edgeType); + $this->graphql->addType($connectionType, $connectionName); + + $fieldConfig = array_except($config, ['connectionTypeName']); + $field = new ConnectionField($fieldConfig); + $field->setType($this->graphql->type($connectionName)); + return $field; + } + + public function connectionFieldFromEdgeTypeAndQueryBuilder($edgeType, $queryBuilderResolver, $config = []) + { + $field = $this->connectionFieldFromEdgeType($edgeType, $config); + $field->setQueryBuilderResolver($queryBuilderResolver); + return $field; + } + + public function toGlobalId($type, $id) + { + return base64_encode($type.':'.$id); + } + + public function fromGlobalId($globalId) + { + $id = explode(':', base64_decode($globalId), 2); + return sizeof($id) === 2 ? [ + 'type' => $id[0], + 'id' => $id[1] + ]:null; + } + + public function getIdFromGlobalId($globalId) + { + $id = $this->fromGlobalId($globalId); + return $id ? $id['id']:null; + } + + public function getTypeFromGlobalId($globalId) + { + $id = $this->fromGlobalId($globalId); + return $id ? $id['type']:null; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/ServiceProvider.php b/src/Folklore/GraphQL/Relay/Relay/ServiceProvider.php new file mode 100644 index 00000000..718314aa --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/ServiceProvider.php @@ -0,0 +1,187 @@ +bootTypes(); + + $this->bootSchemas(); + } + + /** + * Add schemas from config + * + * @return void + */ + protected function bootSchemas() + { + $query = config('graphql.relay.query', []); + $schemas = config('graphql.relay.schemas'); + if ($schemas === null) { + return null; + } elseif ($schemas === '*') { + $schemas = array_keys(config('graphql.schemas', [])); + } else { + $schemas = (array)$schemas; + } + + $allSchemas = $this->app['graphql']->getSchemas(); + foreach ($allSchemas as $name => $schema) { + if (!in_array($name, $schemas)) { + continue; + } + $schema['query'] = array_merge($schema['query'], $query); + $this->app['graphql']->addSchema($name, $schema); + } + } + + /** + * Add types from config + * + * @return void + */ + protected function bootTypes() + { + $types = config('graphql.relay.types'); + if (is_array($types)) { + $this->app['graphql']->addTypes($types); + } + } + + /** + * Register any application services. + * + * @return void + */ + public function register() + { + $this->registerRelay(); + + $this->registerCommands(); + } + + /** + * Register Relay facade + * + * @return void + */ + public function registerRelay() + { + $this->app->singleton('graphql.relay', function ($app) { + $relay = new Relay($app); + return $relay; + }); + } + + /** + * Register console commands + * + * @return void + */ + public function registerCommands() + { + $commands = [ + 'MakeNode', 'MakeMutation', 'MakeInput', 'MakePayload', 'MakeConnection' + ]; + + // We'll simply spin through the list of commands that are migration related + // and register each one of them with an application container. They will + // be resolved in the Artisan start file and registered on the console. + foreach ($commands as $command) { + $this->{'register'.$command.'Command'}(); + } + + $this->commands( + 'command.relay.make.node', + 'command.relay.make.mutation', + 'command.relay.make.input', + 'command.relay.make.payload', + 'command.relay.make.connection' + ); + } + + /** + * Register the "make:graphql:node" migration command. + * + * @return void + */ + public function registerMakeNodeCommand() + { + $this->app->singleton('command.relay.make.node', function ($app) { + return new \Folklore\GraphQL\Relay\Console\NodeMakeCommand($app['files']); + }); + } + + /** + * Register the "make:graphql:mutation" migration command. + * + * @return void + */ + public function registerMakeMutationCommand() + { + $this->app->singleton('command.relay.make.mutation', function ($app) { + return new \Folklore\GraphQL\Relay\Console\MutationMakeCommand($app['files']); + }); + } + + /** + * Register the "make:graphql:input" migration command. + * + * @return void + */ + public function registerMakeInputCommand() + { + $this->app->singleton('command.relay.make.input', function ($app) { + return new \Folklore\GraphQL\Relay\Console\InputMakeCommand($app['files']); + }); + } + + /** + * Register the "make:graphql:payload" migration command. + * + * @return void + */ + public function registerMakePayloadCommand() + { + $this->app->singleton('command.relay.make.payload', function ($app) { + return new \Folklore\GraphQL\Relay\Console\PayloadMakeCommand($app['files']); + }); + } + + /** + * Register the "make:graphql:payload" migration command. + * + * @return void + */ + public function registerMakeConnectionCommand() + { + $this->app->singleton('command.relay.make.connection', function ($app) { + return new \Folklore\GraphQL\Relay\Console\ConnectionMakeCommand($app['files']); + }); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return [ + 'graphql.relay', + 'command.relay.make.node', + 'command.relay.make.input', + 'command.relay.make.payload', + 'command.relay.make.connection' + ]; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Support/ConnectionField.php b/src/Folklore/GraphQL/Relay/Relay/Support/ConnectionField.php new file mode 100644 index 00000000..9e911179 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Support/ConnectionField.php @@ -0,0 +1,13 @@ + [ + 'type' => Type::int(), + 'resolve' => function ($root) { + return $this->getTotalFromRoot($root); + } + ], + 'edges' => [ + 'type' => Type::listOf($this->getEdgeObjectType()), + 'resolve' => function ($root) { + return $this->getEdgesFromRoot($root); + } + ], + 'pageInfo' => [ + 'type' => app('graphql')->type('PageInfo'), + 'resolve' => function ($root) { + return $this->getPageInfoFromRoot($root); + } + ] + ]; + } + + public function getEdgeType() + { + $edgeType = $this->edgeType(); + return $edgeType ? $edgeType:$this->edgeType; + } + + public function setEdgeType($edgeType) + { + $this->edgeType = $edgeType; + return $this; + } + + protected function getEdgeObjectType() + { + $edgeType = $this->getEdgeType(); + $name = $edgeType->config['name'].'Edge'; + app('graphql')->addType(\Folklore\GraphQL\Relay\ConnectionEdgeType::class, $name); + $type = app('graphql')->type($name); + $type->setEdgeType($edgeType); + return $type; + } + + protected function getCursorFromNode($edge) + { + $edgeType = $this->getEdgeType(); + if ($edgeType instanceof InterfaceType) { + $edgeType = $edgeType->config['resolveType']($edge); + } + $resolveId = $edgeType->getField('id')->resolveFn; + return $resolveId($edge); + } + + protected function getTotalFromRoot($root) + { + $total = 0; + if ($root instanceof EdgesCollection) { + $total = $root->getTotal(); + } + return $total; + } + + + protected function getEdgesFromRoot($root) + { + $cursor = $this->getStartCursorFromRoot($root); + $edges = []; + foreach ($root as $item) { + $edges[] = [ + 'cursor' => $cursor !== null ? $cursor:$this->getCursorFromNode($item), + 'node' => $item + ]; + if ($cursor !== null) { + $cursor++; + } + } + return $edges; + } + + protected function getHasPreviousPageFromRoot($root) + { + $hasPreviousPage = false; + if ($root instanceof LengthAwarePaginator) { + $hasPreviousPage = !$root->onFirstPage(); + } elseif ($root instanceof AbstractPaginator) { + $hasPreviousPage = !$root->onFirstPage(); + } elseif ($root instanceof EdgesCollection) { + $hasPreviousPage = $root->getHasPreviousPage(); + } + + return $hasPreviousPage; + } + + protected function getHasNextPageFromRoot($root) + { + $hasNextPage = false; + if ($root instanceof LengthAwarePaginator) { + $hasNextPage = $root->hasMorePages(); + } elseif ($root instanceof EdgesCollection) { + $hasNextPage = $root->getHasNextPage(); + } + + return $hasNextPage; + } + + protected function getStartCursorFromRoot($root) + { + $startCursor = null; + if ($root instanceof EdgesCollection) { + $startCursor = $root->getStartCursor(); + } + + return $startCursor; + } + + protected function getEndCursorFromRoot($root) + { + $endCursor = null; + if ($root instanceof EdgesCollection) { + $endCursor = $root->getEndCursor(); + } + + return $endCursor; + } + + protected function getPageInfoFromRoot($root) + { + $hasPreviousPage = $this->getHasPreviousPageFromRoot($root); + $hasNextPage = $this->getHasNextPageFromRoot($root); + $startCursor = $this->getStartCursorFromRoot($root); + $endCursor = $this->getEndCursorFromRoot($root); + $edges = $startCursor === null || $endCursor === null ? $this->getEdgesFromRoot($root):null; + + return [ + 'hasPreviousPage' => $hasPreviousPage, + 'hasNextPage' => $hasNextPage, + 'startCursor' => $startCursor !== null ? $startCursor:array_get($edges, '0.cursor'), + 'endCursor' => $endCursor !== null ? $endCursor:array_get($edges, (sizeof($edges)-1).'.cursor') + ]; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Support/Facades/Relay.php b/src/Folklore/GraphQL/Relay/Relay/Support/Facades/Relay.php new file mode 100644 index 00000000..fbfdbf6a --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Support/Facades/Relay.php @@ -0,0 +1,16 @@ +inputType(); + return $inputType ? $inputType:$this->inputType; + } + + public function setInputType($inputType) + { + $this->inputType = $inputType; + } + + public function args() + { + return [ + 'input' => [ + 'name' => 'input', + 'type' => $this->getInputType() + ] + ]; + } + + protected function getMutationResponse($response, $clientMutationId) + { + $mutationResponse = new MutationResponse(); + $mutationResponse->setNode($response); + $mutationResponse->setClientMutationId($clientMutationId); + + return $mutationResponse; + } + + protected function resolveClientMutationId($root, $args) + { + return array_get($args, 'input.clientMutationId'); + } + + public function getResolver() + { + $resolver = parent::getResolver(); + + return function () use ($resolver) { + $args = func_get_args(); + $response = call_user_func_array($resolver, $args); + $clientMutationId = call_user_func_array([$this, 'resolveClientMutationId'], $args); + $response = $this->getMutationResponse($response, $clientMutationId); + return $response; + }; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Support/NodeContract.php b/src/Folklore/GraphQL/Relay/Relay/Support/NodeContract.php new file mode 100644 index 00000000..580fcd4c --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Support/NodeContract.php @@ -0,0 +1,8 @@ + [ + 'name' => 'first', + 'type' => Type::int() + ], + 'last' => [ + 'name' => 'last', + 'type' => Type::int() + ], + 'after' => [ + 'name' => 'after', + 'type' => Type::string() + ], + 'before' => [ + 'name' => 'before', + 'type' => Type::string() + ] + ]; + } + + public function getArgs() + { + $args = parent::getArgs(); + + $connectionArgs = $this->connectionArgs(); + + return array_merge($connectionArgs, $args); + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Support/Traits/HasClientMutationIdField.php b/src/Folklore/GraphQL/Relay/Relay/Support/Traits/HasClientMutationIdField.php new file mode 100644 index 00000000..1df513b9 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Support/Traits/HasClientMutationIdField.php @@ -0,0 +1,21 @@ + Type::nonNull(Type::string()) + ]; + + return $fields; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Support/Traits/ResolvesFromQueryBuilder.php b/src/Folklore/GraphQL/Relay/Relay/Support/Traits/ResolvesFromQueryBuilder.php new file mode 100644 index 00000000..2b61b5b8 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Support/Traits/ResolvesFromQueryBuilder.php @@ -0,0 +1,151 @@ +queryBuilderResolver; + } + + public function setQueryBuilderResolver($queryBuilderResolver) + { + $this->queryBuilderResolver = $queryBuilderResolver; + return $queryBuilderResolver; + } + + protected function scopeAfter($query, $id) + { + $query->where('id', '>=', $id); + } + + protected function scopeBefore($query, $id) + { + $query->where('id', '<=', $id); + } + + protected function scopeFirst($query, $value) + { + $query->orderBy('id', 'ASC'); + $query->take($value); + } + + protected function scopeLast($query, $value) + { + $query->orderBy('id', 'DESC'); + $query->take($value); + } + + protected function getCountFromQuery($query) + { + $countQuery = clone $query; + if ($countQuery instanceof \Illuminate\Database\Eloquent\Relations\Relation) { + $countQuery->getBaseQuery()->orders = null; + } else if ($countQuery instanceof \Illuminate\Database\Eloquent\Builder) { + $countQuery->getQuery()->orders = null; + } else if( $countQuery instanceof \Illuminate\Database\Query\Builder) { + $countQuery->orders = null; + } + return $countQuery->count(); + } + + protected function resolveQueryBuilderFromRoot($root, $args) + { + if (method_exists($this, 'resolveQueryBuilder')) { + $queryBuilderResolver = [$this, 'resolveQueryBuilder']; + } else { + $queryBuilderResolver = $this->getQueryBuilderResolver(); + } + + if (!$queryBuilderResolver) { + return null; + } + + $args = func_get_args(); + return call_user_func_array($queryBuilderResolver, $args); + } + + protected function resolveItemsFromQueryBuilder($query) + { + return $query->get(); + } + + protected function getCollectionFromItems($items, $offset, $limit, $total, $hasPreviousPage, $hasNextPage) + { + $collection = new EdgesCollection($items); + $collection->setTotal($total); + $collection->setStartCursor($offset); + $collection->setEndCursor($offset + $limit - 1); + $collection->setHasNextPage($hasNextPage); + $collection->setHasPreviousPage($hasPreviousPage); + return $collection; + } + + public function resolve($root, $args) + { + // Get the query builder + $arguments = func_get_args(); + $query = call_user_func_array([$this, 'resolveQueryBuilderFromRoot'], $arguments); + + // If there is no query builder returned, try to use the parent resolve method. + if (!$query) { + if (method_exists('parent', 'resolve')) { + return call_user_func_array(['parent', 'resolve'], $arguments); + } else { + return null; + } + } + + $after = array_get($args, 'after'); + $before = array_get($args, 'before'); + $first = array_get($args, 'first'); + $last = array_get($args, 'last'); + + $count = $this->getCountFromQuery($query); + $offset = 0; + $limit = 0; + + if ($first !== null) { + $limit = $first; + $offset = 0; + if ($after !== null) { + $offset = $after + 1; + } + if ($before !== null) { + $limit = min(max(0, $before - $offset), $limit); + } + } else if ($last !== null) { + $limit = $last; + $offset = $count - $limit; + if ($before !== null) { + $offset = max(0, $before - $limit); + $limit = min($before - $offset, $limit); + } + if ($after !== null) { + $d = max(0, $after + 1 - $offset); + $limit -= $d; + $offset += $d; + } + } + $offset = max(0, $offset); + $limit = min($count - $offset, $limit); + + $query->skip($offset)->take($limit); + + $hasNextPage = ($offset + $limit) < $count; + $hasPreviousPage = $offset > 0; + + $resolveItemsArguments = array_merge([$query], $arguments); + $items = call_user_func_array([$this, 'resolveItemsFromQueryBuilder'], $resolveItemsArguments); + $collection = $this->getCollectionFromItems($items, $offset, $limit, $count, $hasPreviousPage, $hasNextPage); + + return $collection; + } +} diff --git a/src/Folklore/GraphQL/Relay/Relay/Support/Traits/TypeIsNode.php b/src/Folklore/GraphQL/Relay/Relay/Support/Traits/TypeIsNode.php new file mode 100644 index 00000000..ce839e8a --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Relay/Support/Traits/TypeIsNode.php @@ -0,0 +1,59 @@ +getIdResolverFromFields($currentFields); + $nodeIdField = $this->getNodeIdField(); + $nodeIdField->setIdResolver($idResolver); + $currentFields['id'] = $nodeIdField->toArray(); + + return $currentFields; + } + + protected function getNodeIdField() + { + $nodeIdField = new NodeIdField(); + $nodeIdField->setIdType($this->name); + return $nodeIdField; + } + + protected function getIdResolverFromFields($fields) + { + $idResolver = null; + $originalResolver = array_get($fields, 'id.resolve'); + if ($originalResolver) { + $idResolver = function () use ($originalResolver) { + $id = call_user_func_array($originalResolver, func_get_args()); + return $id; + }; + } else { + $idResolver = function ($root) { + return array_get($root, 'id'); + }; + } + + return $idResolver; + } + + protected function relayInterfaces() + { + return [ + app('graphql')->type('Node') + ]; + } + + public function getInterfaces() + { + $interfaces = parent::getInterfaces(); + $relayInterfaces = $this->relayInterfaces(); + return array_merge($interfaces, $relayInterfaces); + } +} diff --git a/src/Folklore/GraphQL/Relay/ServiceProvider.php b/src/Folklore/GraphQL/Relay/ServiceProvider.php new file mode 100644 index 00000000..718314aa --- /dev/null +++ b/src/Folklore/GraphQL/Relay/ServiceProvider.php @@ -0,0 +1,187 @@ +bootTypes(); + + $this->bootSchemas(); + } + + /** + * Add schemas from config + * + * @return void + */ + protected function bootSchemas() + { + $query = config('graphql.relay.query', []); + $schemas = config('graphql.relay.schemas'); + if ($schemas === null) { + return null; + } elseif ($schemas === '*') { + $schemas = array_keys(config('graphql.schemas', [])); + } else { + $schemas = (array)$schemas; + } + + $allSchemas = $this->app['graphql']->getSchemas(); + foreach ($allSchemas as $name => $schema) { + if (!in_array($name, $schemas)) { + continue; + } + $schema['query'] = array_merge($schema['query'], $query); + $this->app['graphql']->addSchema($name, $schema); + } + } + + /** + * Add types from config + * + * @return void + */ + protected function bootTypes() + { + $types = config('graphql.relay.types'); + if (is_array($types)) { + $this->app['graphql']->addTypes($types); + } + } + + /** + * Register any application services. + * + * @return void + */ + public function register() + { + $this->registerRelay(); + + $this->registerCommands(); + } + + /** + * Register Relay facade + * + * @return void + */ + public function registerRelay() + { + $this->app->singleton('graphql.relay', function ($app) { + $relay = new Relay($app); + return $relay; + }); + } + + /** + * Register console commands + * + * @return void + */ + public function registerCommands() + { + $commands = [ + 'MakeNode', 'MakeMutation', 'MakeInput', 'MakePayload', 'MakeConnection' + ]; + + // We'll simply spin through the list of commands that are migration related + // and register each one of them with an application container. They will + // be resolved in the Artisan start file and registered on the console. + foreach ($commands as $command) { + $this->{'register'.$command.'Command'}(); + } + + $this->commands( + 'command.relay.make.node', + 'command.relay.make.mutation', + 'command.relay.make.input', + 'command.relay.make.payload', + 'command.relay.make.connection' + ); + } + + /** + * Register the "make:graphql:node" migration command. + * + * @return void + */ + public function registerMakeNodeCommand() + { + $this->app->singleton('command.relay.make.node', function ($app) { + return new \Folklore\GraphQL\Relay\Console\NodeMakeCommand($app['files']); + }); + } + + /** + * Register the "make:graphql:mutation" migration command. + * + * @return void + */ + public function registerMakeMutationCommand() + { + $this->app->singleton('command.relay.make.mutation', function ($app) { + return new \Folklore\GraphQL\Relay\Console\MutationMakeCommand($app['files']); + }); + } + + /** + * Register the "make:graphql:input" migration command. + * + * @return void + */ + public function registerMakeInputCommand() + { + $this->app->singleton('command.relay.make.input', function ($app) { + return new \Folklore\GraphQL\Relay\Console\InputMakeCommand($app['files']); + }); + } + + /** + * Register the "make:graphql:payload" migration command. + * + * @return void + */ + public function registerMakePayloadCommand() + { + $this->app->singleton('command.relay.make.payload', function ($app) { + return new \Folklore\GraphQL\Relay\Console\PayloadMakeCommand($app['files']); + }); + } + + /** + * Register the "make:graphql:payload" migration command. + * + * @return void + */ + public function registerMakeConnectionCommand() + { + $this->app->singleton('command.relay.make.connection', function ($app) { + return new \Folklore\GraphQL\Relay\Console\ConnectionMakeCommand($app['files']); + }); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return [ + 'graphql.relay', + 'command.relay.make.node', + 'command.relay.make.input', + 'command.relay.make.payload', + 'command.relay.make.connection' + ]; + } +} diff --git a/src/Folklore/GraphQL/Relay/Support/ConnectionField.php b/src/Folklore/GraphQL/Relay/Support/ConnectionField.php new file mode 100644 index 00000000..9e911179 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Support/ConnectionField.php @@ -0,0 +1,13 @@ + [ + 'type' => Type::int(), + 'resolve' => function ($root) { + return $this->getTotalFromRoot($root); + } + ], + 'edges' => [ + 'type' => Type::listOf($this->getEdgeObjectType()), + 'resolve' => function ($root) { + return $this->getEdgesFromRoot($root); + } + ], + 'pageInfo' => [ + 'type' => app('graphql')->type('PageInfo'), + 'resolve' => function ($root) { + return $this->getPageInfoFromRoot($root); + } + ] + ]; + } + + public function getEdgeType() + { + $edgeType = $this->edgeType(); + return $edgeType ? $edgeType:$this->edgeType; + } + + public function setEdgeType($edgeType) + { + $this->edgeType = $edgeType; + return $this; + } + + protected function getEdgeObjectType() + { + $edgeType = $this->getEdgeType(); + $name = $edgeType->config['name'].'Edge'; + app('graphql')->addType(\Folklore\GraphQL\Relay\ConnectionEdgeType::class, $name); + $type = app('graphql')->type($name); + $type->setEdgeType($edgeType); + return $type; + } + + protected function getCursorFromNode($edge) + { + $edgeType = $this->getEdgeType(); + if ($edgeType instanceof InterfaceType) { + $edgeType = $edgeType->config['resolveType']($edge); + } + $resolveId = $edgeType->getField('id')->resolveFn; + return $resolveId($edge); + } + + protected function getTotalFromRoot($root) + { + $total = 0; + if ($root instanceof EdgesCollection) { + $total = $root->getTotal(); + } + return $total; + } + + + protected function getEdgesFromRoot($root) + { + $cursor = $this->getStartCursorFromRoot($root); + $edges = []; + foreach ($root as $item) { + $edges[] = [ + 'cursor' => $cursor !== null ? $cursor:$this->getCursorFromNode($item), + 'node' => $item + ]; + if ($cursor !== null) { + $cursor++; + } + } + return $edges; + } + + protected function getHasPreviousPageFromRoot($root) + { + $hasPreviousPage = false; + if ($root instanceof LengthAwarePaginator) { + $hasPreviousPage = !$root->onFirstPage(); + } elseif ($root instanceof AbstractPaginator) { + $hasPreviousPage = !$root->onFirstPage(); + } elseif ($root instanceof EdgesCollection) { + $hasPreviousPage = $root->getHasPreviousPage(); + } + + return $hasPreviousPage; + } + + protected function getHasNextPageFromRoot($root) + { + $hasNextPage = false; + if ($root instanceof LengthAwarePaginator) { + $hasNextPage = $root->hasMorePages(); + } elseif ($root instanceof EdgesCollection) { + $hasNextPage = $root->getHasNextPage(); + } + + return $hasNextPage; + } + + protected function getStartCursorFromRoot($root) + { + $startCursor = null; + if ($root instanceof EdgesCollection) { + $startCursor = $root->getStartCursor(); + } + + return $startCursor; + } + + protected function getEndCursorFromRoot($root) + { + $endCursor = null; + if ($root instanceof EdgesCollection) { + $endCursor = $root->getEndCursor(); + } + + return $endCursor; + } + + protected function getPageInfoFromRoot($root) + { + $hasPreviousPage = $this->getHasPreviousPageFromRoot($root); + $hasNextPage = $this->getHasNextPageFromRoot($root); + $startCursor = $this->getStartCursorFromRoot($root); + $endCursor = $this->getEndCursorFromRoot($root); + $edges = $startCursor === null || $endCursor === null ? $this->getEdgesFromRoot($root):null; + + return [ + 'hasPreviousPage' => $hasPreviousPage, + 'hasNextPage' => $hasNextPage, + 'startCursor' => $startCursor !== null ? $startCursor:array_get($edges, '0.cursor'), + 'endCursor' => $endCursor !== null ? $endCursor:array_get($edges, (sizeof($edges)-1).'.cursor') + ]; + } +} diff --git a/src/Folklore/GraphQL/Relay/Support/Facades/Relay.php b/src/Folklore/GraphQL/Relay/Support/Facades/Relay.php new file mode 100644 index 00000000..fbfdbf6a --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Support/Facades/Relay.php @@ -0,0 +1,16 @@ +inputType(); + return $inputType ? $inputType:$this->inputType; + } + + public function setInputType($inputType) + { + $this->inputType = $inputType; + } + + public function args() + { + return [ + 'input' => [ + 'name' => 'input', + 'type' => $this->getInputType() + ] + ]; + } + + protected function getMutationResponse($response, $clientMutationId) + { + $mutationResponse = new MutationResponse(); + $mutationResponse->setNode($response); + $mutationResponse->setClientMutationId($clientMutationId); + + return $mutationResponse; + } + + protected function resolveClientMutationId($root, $args) + { + return array_get($args, 'input.clientMutationId'); + } + + public function getResolver() + { + $resolver = parent::getResolver(); + + return function () use ($resolver) { + $args = func_get_args(); + $response = call_user_func_array($resolver, $args); + $clientMutationId = call_user_func_array([$this, 'resolveClientMutationId'], $args); + $response = $this->getMutationResponse($response, $clientMutationId); + return $response; + }; + } +} diff --git a/src/Folklore/GraphQL/Relay/Support/NodeContract.php b/src/Folklore/GraphQL/Relay/Support/NodeContract.php new file mode 100644 index 00000000..580fcd4c --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Support/NodeContract.php @@ -0,0 +1,8 @@ + [ + 'name' => 'first', + 'type' => Type::int() + ], + 'last' => [ + 'name' => 'last', + 'type' => Type::int() + ], + 'after' => [ + 'name' => 'after', + 'type' => Type::string() + ], + 'before' => [ + 'name' => 'before', + 'type' => Type::string() + ] + ]; + } + + public function getArgs() + { + $args = parent::getArgs(); + + $connectionArgs = $this->connectionArgs(); + + return array_merge($connectionArgs, $args); + } +} diff --git a/src/Folklore/GraphQL/Relay/Support/Traits/HasClientMutationIdField.php b/src/Folklore/GraphQL/Relay/Support/Traits/HasClientMutationIdField.php new file mode 100644 index 00000000..1df513b9 --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Support/Traits/HasClientMutationIdField.php @@ -0,0 +1,21 @@ + Type::nonNull(Type::string()) + ]; + + return $fields; + } +} diff --git a/src/Folklore/GraphQL/Relay/Support/Traits/ResolvesFromQueryBuilder.php b/src/Folklore/GraphQL/Relay/Support/Traits/ResolvesFromQueryBuilder.php new file mode 100644 index 00000000..159d011c --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Support/Traits/ResolvesFromQueryBuilder.php @@ -0,0 +1,159 @@ +queryBuilderResolver; + } + + public function setQueryBuilderResolver($queryBuilderResolver) + { + $this->queryBuilderResolver = $queryBuilderResolver; + return $queryBuilderResolver; + } + + protected function scopeAfter($query, $id) + { + $query->where('id', '>=', $id); + } + + protected function scopeBefore($query, $id) + { + $query->where('id', '<=', $id); + } + + protected function scopeFirst($query, $value) + { + $query->orderBy('id', 'ASC'); + $query->take($value); + } + + protected function scopeLast($query, $value) + { + $query->orderBy('id', 'DESC'); + $query->take($value); + } + + protected function getCountFromQuery($query) + { + $countQuery = clone $query; + if ($countQuery instanceof \Illuminate\Database\Eloquent\Relations\Relation) { + $countQuery->getBaseQuery()->orders = null; + } else if ($countQuery instanceof \Illuminate\Database\Eloquent\Builder) { + $countQuery->getQuery()->orders = null; + } else if( $countQuery instanceof \Illuminate\Database\Query\Builder) { + $countQuery->orders = null; + } + return $countQuery->count(); + } + + protected function resolveQueryBuilderFromRoot($root, $args) + { + if (method_exists($this, 'resolveQueryBuilder')) { + $queryBuilderResolver = [$this, 'resolveQueryBuilder']; + } else { + $queryBuilderResolver = $this->getQueryBuilderResolver(); + } + + if (!$queryBuilderResolver) { + return null; + } + + $args = func_get_args(); + return call_user_func_array($queryBuilderResolver, $args); + } + + protected function resolveItemsFromQueryBuilder($query) + { + /* @var $query \Illuminate\Database\Eloquent\Builder */ +// throw new \Exception("Query class is " . $query->toSql()); + + return $query->get(); + } + + protected function getCollectionFromItems($items, $offset, $limit, $total, $hasPreviousPage, $hasNextPage) + { + $collection = new EdgesCollection($items); + $collection->setTotal($total); + $collection->setStartCursor($offset); + $collection->setEndCursor($offset + $limit - 1); + $collection->setHasNextPage($hasNextPage); + $collection->setHasPreviousPage($hasPreviousPage); + return $collection; + } + + public function resolve($root, $args) + { + // Get the query builder + $arguments = func_get_args(); + $query = call_user_func_array([$this, 'resolveQueryBuilderFromRoot'], $arguments); + + // If there is no query builder returned, try to use the parent resolve method. + if (!$query) { + if (method_exists('parent', 'resolve')) { + return call_user_func_array(['parent', 'resolve'], $arguments); + } else { + return null; + } + } + + $after = array_get($args, 'after'); + $before = array_get($args, 'before'); + $first = array_get($args, 'first'); + $last = array_get($args, 'last'); + + $count = $this->getCountFromQuery($query); + $offset = 0; + $limit = 0; + + if ($first !== null) { + $limit = $first; + $offset = 0; + if ($after !== null) { + $offset = $after + 1; + } + if ($before !== null) { + $limit = min(max(0, $before - $offset), $limit); + } + } else if ($last !== null) { + $limit = $last; + $offset = $count - $limit; + if ($before !== null) { + $offset = max(0, $before - $limit); + $limit = min($before - $offset, $limit); + } + if ($after !== null) { + $d = max(0, $after + 1 - $offset); + $limit -= $d; + $offset += $d; + } + } + $offset = max(0, $offset); + $limit = min($count - $offset, $limit); + + if ($offset) { + $query->skip($offset); + } + if ($limit) { + $query->take($limit); + } + + $hasNextPage = ($offset + $limit) < $count; + $hasPreviousPage = $offset > 0; + + $resolveItemsArguments = array_merge([$query], $arguments); + $items = call_user_func_array([$this, 'resolveItemsFromQueryBuilder'], $resolveItemsArguments); + $collection = $this->getCollectionFromItems($items, $offset, $limit, $count, $hasPreviousPage, $hasNextPage); + + return $collection; + } +} diff --git a/src/Folklore/GraphQL/Relay/Support/Traits/TypeIsNode.php b/src/Folklore/GraphQL/Relay/Support/Traits/TypeIsNode.php new file mode 100644 index 00000000..ce839e8a --- /dev/null +++ b/src/Folklore/GraphQL/Relay/Support/Traits/TypeIsNode.php @@ -0,0 +1,59 @@ +getIdResolverFromFields($currentFields); + $nodeIdField = $this->getNodeIdField(); + $nodeIdField->setIdResolver($idResolver); + $currentFields['id'] = $nodeIdField->toArray(); + + return $currentFields; + } + + protected function getNodeIdField() + { + $nodeIdField = new NodeIdField(); + $nodeIdField->setIdType($this->name); + return $nodeIdField; + } + + protected function getIdResolverFromFields($fields) + { + $idResolver = null; + $originalResolver = array_get($fields, 'id.resolve'); + if ($originalResolver) { + $idResolver = function () use ($originalResolver) { + $id = call_user_func_array($originalResolver, func_get_args()); + return $id; + }; + } else { + $idResolver = function ($root) { + return array_get($root, 'id'); + }; + } + + return $idResolver; + } + + protected function relayInterfaces() + { + return [ + app('graphql')->type('Node') + ]; + } + + public function getInterfaces() + { + $interfaces = parent::getInterfaces(); + $relayInterfaces = $this->relayInterfaces(); + return array_merge($interfaces, $relayInterfaces); + } +}