├── LICENSE ├── README.md ├── composer.json ├── docs ├── .gitignore ├── .readthedocs.yaml ├── README.md ├── _static │ └── styles.css ├── about.rst ├── attributes.rst ├── banner.png ├── composer.lock ├── computed-fields.rst ├── conf-orig.py ├── conf.py ├── containers.rst ├── custom-doctrine-types.rst ├── driver.rst ├── events.rst ├── favicon.ico ├── footer.rst ├── index.rst ├── install.rst ├── just-the-basics.rst ├── metadata.rst ├── mutations.rst ├── queries.rst ├── requirements.txt ├── strategies.rst ├── tips.rst ├── types.rst ├── upgrade.rst └── versions.rst ├── src ├── Attribute │ ├── Association.php │ ├── Entity.php │ ├── ExcludeFilters.php │ └── Field.php ├── Buildable.php ├── Config.php ├── Container.php ├── Driver.php ├── Event │ ├── Criteria.php │ ├── EntityDefinition.php │ ├── Metadata.php │ └── QueryBuilder.php ├── Filter │ ├── FilterFactory.php │ ├── Filters.php │ ├── InputObjectType │ │ ├── Association.php │ │ ├── Between.php │ │ └── Field.php │ └── QueryBuilder.php ├── Hydrator │ ├── HydratorContainer.php │ └── Strategy │ │ ├── AssociationDefault.php │ │ ├── Collection.php │ │ ├── FieldDefault.php │ │ ├── ToBoolean.php │ │ ├── ToFloat.php │ │ └── ToInteger.php ├── Input │ └── InputFactory.php ├── Metadata │ ├── Common │ │ └── MetadataFactory.php │ ├── GlobalEnable.php │ └── MetadataFactory.php ├── Resolve │ ├── FieldResolver.php │ ├── ResolveCollectionFactory.php │ └── ResolveEntityFactory.php ├── Services.php └── Type │ ├── Blob.php │ ├── Connection.php │ ├── Date.php │ ├── DateImmutable.php │ ├── DateTime.php │ ├── DateTimeImmutable.php │ ├── DateTimeTZ.php │ ├── DateTimeTZImmutable.php │ ├── Entity │ ├── Entity.php │ └── EntityTypeContainer.php │ ├── Json.php │ ├── Node.php │ ├── PageInfo.php │ ├── Pagination.php │ ├── Time.php │ ├── TimeImmutable.php │ └── TypeContainer.php └── testdatabase.sqlite /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present API Skeletons 2 | Copyright (c) 2018 phpro 3 | Copyright (c) 2015-present Kévin Dunglas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 |

5 | 6 | 7 | GraphQL Type Driver for Doctrine ORM 8 | ==================================== 9 | 10 | [![Build Status](https://github.com/API-Skeletons/doctrine-orm-graphql/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/API-Skeletons/doctrine-orm-graphql/actions/workflows/continuous-integration.yml?query=branch%3Amain) 11 | [![Code Coverage](https://codecov.io/gh/API-Skeletons/doctrine-orm-graphql/branch/main/graphs/badge.svg)](https://codecov.io/gh/API-Skeletons/doctrine-orm-graphql/branch/main) 12 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/API-Skeletons/doctrine-orm-graphql/badges/quality-score.png?b=12.0.x)](https://scrutinizer-ci.com/g/API-Skeletons/doctrine-orm-graphql/?branch=12.0.x) 13 | [![PHPStan](https://img.shields.io/badge/PHPStan-level%205-brightgreen.svg)](https://img.shields.io/badge/PHPStan-level%205-brightgreen.svg) 14 | [![psalm](https://img.shields.io/badge/psalm-level%204-brightgreen.svg)](https://img.shields.io/badge/psalm-level%204-brightgreen.svg) 15 | [![PHP Version](https://img.shields.io/badge/PHP-8.1%2b-blue)](https://img.shields.io/badge/PHP-8.1%2b-blue) 16 | [![License](https://poser.pugx.org/api-skeletons/doctrine-orm-graphql/license)](//packagist.org/packages/api-skeletons/doctrine-orm-graphql) 17 | 18 | GraphQL, with types so neat, 19 | Felt a longing, a database heat. 20 | "I'd love," it would sigh, 21 | "To be SQL, oh my! 22 | With relations and joins, oh so sweet!" 23 | 24 | This library provides a GraphQL driver for Doctrine ORM for use with the [webonyx/graphql-php](https://github.com/webonyx/graphql-php) library. 25 | It **does not** try to redefine how that excellent library operates. Instead, it creates types to be used 26 | within the framework that library provides. 27 | 28 | Many other GraphQL libraries for Doctrine ORM are available. 29 | Some of these such as [overblog/graphql-bundle](https://github.com/overblog/GraphQLBundle/tree/master) 30 | and [API Platform](https://api-platform.com/) are integrations into frameworks. But all of these libraries 31 | use the same underlying library, [webonyx/graphql-php](https://github.com/webonyx/graphql-php) and that library 32 | has its own way of doing things. This library is a driver for that library and together they are framework agnostic. 33 | 34 | 35 | Installation 36 | ------------ 37 | 38 | Via composer: 39 | 40 | ```bash 41 | composer require api-skeletons/doctrine-orm-graphql 42 | ``` 43 | 44 | 45 | Documentation 46 | ------------- 47 | 48 | Full documentation is available at https://doctrine-orm-graphql.apiskeletons.dev or in the [docs](https://github.com/api-skeletons/doctrine-orm-graphql/blob/master/docs) directory. 49 | 50 | 51 | Versions 52 | -------- 53 | 54 | * 12.x - Supports [league/event](https://github.com/thephpleague/event) version 3.0 and is PSR-14 compliant 55 | * 11.x - Supports [league/event](https://github.com/thephpleague/event) version 2.2 56 | 57 | More information [in the documentation](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/versions.html). 58 | 59 | 60 | Examples 61 | -------- 62 | 63 | The **LDOG Stack**: Laravel, Doctrine ORM, and GraphQL uses this library: https://ldog.apiskeletons.dev 64 | 65 | For an working implementation see https://graphql.lcdb.org and the corresonding application at https://github.com/lcdborg/graphql.lcdb.org. 66 | 67 | 68 | Features 69 | -------- 70 | 71 | * Supports all [Doctrine Types](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/types.html#data-type-mappings) and allows custom types 72 | * Pagination with the [GraphQL Complete Connection Model](https://graphql.org/learn/pagination/#complete-connection-model) 73 | * [Filtering of sub-collections](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/queries.html) 74 | * [Events](https://github.com/API-Skeletons/doctrine-orm-graphql#events) for modifying queries, entity types and more 75 | * [Multiple configuration group support](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/driver.html#group) 76 | 77 | 78 | Quick Start 79 | ----------- 80 | 81 | Add attributes to your Doctrine entities or see [globalEnable](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/config.html#globalenable) for all entities in your schema without attribute configuration. 82 | 83 | ```php 84 | use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL; 85 | 86 | #[GraphQL\Entity] 87 | class Artist 88 | { 89 | #[GraphQL\Field] 90 | public $id; 91 | 92 | #[GraphQL\Field] 93 | public $name; 94 | 95 | #[GraphQL\Association] 96 | public $performances; 97 | } 98 | 99 | #[GraphQL\Entity] 100 | class Performance 101 | { 102 | #[GraphQL\Field] 103 | public $id; 104 | 105 | #[GraphQL\Field] 106 | public $venue; 107 | 108 | /** 109 | * Not all fields need attributes. 110 | * Only add attribues to fields you want available in GraphQL 111 | */ 112 | public $city; 113 | } 114 | ``` 115 | 116 | Create the driver and GraphQL schema 117 | 118 | ```php 119 | use ApiSkeletons\Doctrine\ORM\GraphQL\Driver; 120 | use Doctrine\ORM\EntityManager; 121 | use GraphQL\Type\Definition\ObjectType; 122 | use GraphQL\Type\Definition\Type; 123 | use GraphQL\Type\Schema; 124 | 125 | $driver = new Driver($entityManager); 126 | 127 | $schema = new Schema([ 128 | 'query' => new ObjectType([ 129 | 'name' => 'query', 130 | 'fields' => [ 131 | 'artists' => $driver->completeConnection(Artist::class), 132 | ], 133 | ]), 134 | 'mutation' => new ObjectType([ 135 | 'name' => 'mutation', 136 | 'fields' => [ 137 | 'artistUpdateName' => [ 138 | 'type' => $driver->type(Artist::class), 139 | 'args' => [ 140 | 'id' => Type::nonNull(Type::id()), 141 | 'input' => Type::nonNull($driver->input(Artist::class, ['name'])), 142 | ], 143 | 'resolve' => function ($root, $args) use ($driver): Artist { 144 | $artist = $driver->get(EntityManager::class) 145 | ->getRepository(Artist::class) 146 | ->find($args['id']); 147 | 148 | $artist->setName($args['input']['name']); 149 | $driver->get(EntityManager::class)->flush(); 150 | 151 | return $artist; 152 | }, 153 | ], 154 | ], 155 | ]), 156 | ]); 157 | ``` 158 | 159 | Run GraphQL queries 160 | 161 | ```php 162 | use GraphQL\GraphQL; 163 | 164 | $query = '{ 165 | artists { 166 | edges { 167 | node { 168 | id 169 | name 170 | performances { 171 | edges { 172 | node { 173 | venue 174 | } 175 | } 176 | } 177 | } 178 | } 179 | } 180 | }'; 181 | 182 | $result = GraphQL::executeQuery( 183 | schema: $schema, 184 | source: $query, 185 | variableValues: null, 186 | operationName: null 187 | ); 188 | 189 | $output = $result->toArray(); 190 | ``` 191 | 192 | Run GraphQL mutations 193 | 194 | ```php 195 | use GraphQL\GraphQL; 196 | 197 | $query = ' 198 | mutation ArtistUpdateName($id: Int!, $name: String!) { 199 | artistUpdateName(id: $id, input: { name: $name }) { 200 | id 201 | name 202 | } 203 | } 204 | '; 205 | 206 | $result = GraphQL::executeQuery( 207 | schema: $schema, 208 | source: $query, 209 | variableValues: [ 210 | 'id' => 1, 211 | 'name' => 'newName', 212 | ], 213 | operationName: 'ArtistUpdateName' 214 | ); 215 | 216 | $output = $result->toArray(); 217 | ``` 218 | 219 | 220 | Filters 221 | ------- 222 | 223 | For every enabled field and association, filters are available for querying. 224 | 225 | Example 226 | 227 | ```gql 228 | { 229 | artists ( 230 | filter: { 231 | name: { 232 | contains: "Dead" 233 | } 234 | } 235 | ) { 236 | edges { 237 | node { 238 | id 239 | name 240 | performances ( 241 | filter: { 242 | venue: { 243 | eq: "The Fillmore" 244 | } 245 | } 246 | ) { 247 | edges { 248 | node { 249 | venue 250 | } 251 | } 252 | } 253 | } 254 | } 255 | } 256 | } 257 | ``` 258 | 259 | Each field has their own set of filters. Based on the field type, some or all of the following filters are available: 260 | 261 | * eq - Equals. 262 | * neq - Not equals. 263 | * lt - Less than. 264 | * lte - Less than or equal to. 265 | * gt - Greater than. 266 | * gte - Greater than or equal to. 267 | * isnull - Is null. If value is true, the field must be null. If value is false, the field must not be null. 268 | * between - Between. Identical to using gte & lte on the same field. Give values as `low, high`. 269 | * in - Exists within an array. 270 | * notin - Does not exist within an array. 271 | * startwith - A like query with a wildcard on the right side of the value. 272 | * endswith - A like query with a wildcard on the left side of the value. 273 | * contains - A like query. 274 | 275 | You may [exclude any filter](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/attributes.html#entity) from any entity, association, or globally. 276 | 277 | 278 | History 279 | ------- 280 | 281 | The roots of this project go back to May 2018 with https://github.com/API-Skeletons/zf-doctrine-graphql; written for Zend Framework 2. It was migrated to the framework agnostic https://packagist.org/packages/api-skeletons/doctrine-graphql but the name of that repository was incorrect because it did not specify ORM only. So this repository was created and the others were abandoned. 282 | 283 | 284 | License 285 | ------- 286 | 287 | See [LICENSE](https://github.com/api-skeletons/doctrine-orm-graphql/blob/master/LICENSE). 288 | 289 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-skeletons/doctrine-orm-graphql", 3 | "description": "GraphQL Type Driver for Doctrine ORM", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Tom H Anderson", 9 | "email": "tom.h.anderson@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8.1", 14 | "doctrine/orm": "^2.18 || ^3.0", 15 | "doctrine/doctrine-laminas-hydrator": "^3.2", 16 | "webonyx/graphql-php": "^v15.0", 17 | "psr/container": "^1.1 || ^2.0", 18 | "league/event": "^3.0" 19 | }, 20 | "require-dev": { 21 | "doctrine/coding-standard": "^11.0 || ^12.0", 22 | "doctrine/dbal": "^3.1 || ^4.0", 23 | "phpunit/phpunit": "^9.6", 24 | "vimeo/psalm": "^5.4", 25 | "symfony/cache": "^5.3||^6.2", 26 | "php-parallel-lint/php-parallel-lint": "^1.3.2", 27 | "phpstan/phpstan": "^1.12 || ^2.0" 28 | }, 29 | "suggest": { 30 | "ramsey/uuid-doctrine": "Support for an UUID Doctrine type" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "ApiSkeletons\\Doctrine\\ORM\\GraphQL\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "ApiSkeletonsTest\\Doctrine\\ORM\\GraphQL\\": "test/" 40 | } 41 | }, 42 | "scripts": { 43 | "test": [ 44 | "vendor/bin/parallel-lint ./src/ ./test", 45 | "vendor/bin/phpcs", 46 | "vendor/bin/psalm", 47 | "vendor/bin/phpstan analyze src --level=5", 48 | "vendor/bin/phpunit" 49 | ], 50 | "coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage" 51 | }, 52 | "config": { 53 | "allow-plugins": { 54 | "dealerdirect/phpcodesniffer-composer-installer": true 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | /vendor/ 3 | /coverage/ 4 | .idea/ 5 | .phpcs-cache 6 | .phpunit.result.cache 7 | -------------------------------------------------------------------------------- /docs/.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # We recommend specifying your dependencies to enable reproducible builds: 19 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - requirements: docs/requirements.txt 23 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | API-Skeletons/doctrine-orm-graphql Documentation 2 | ================================================ 3 | 4 | Thanks for your interest in this project. Please share any success stories 5 | with us: [contact@apiskeletons.com](mailto:contact@apiskeletons.com) 6 | 7 | [Read The Documentation](https://doctrine-orm-graphql.apiskeletons.dev/en/latest/) 8 | ------------------------ 9 | -------------------------------------------------------------------------------- /docs/_static/styles.css: -------------------------------------------------------------------------------- 1 | table { 2 | width: 100%; 3 | border-collapse: collapse; /* Optional: collapses borders for a cleaner look */ 4 | } 5 | th, td { 6 | border: 1px solid black; /* Optional: adds borders to cells */ 7 | padding: 5px; /* Optional: adds padding to cells */ 8 | } 9 | -------------------------------------------------------------------------------- /docs/about.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | About 3 | ===== 4 | 5 | Authored by Tom H Anderson of 6 | `API Skeletons `_, 7 | a member of the Doctrine maintainers. 8 | 9 | This project provides a Doctrine ORM Driver to be used with 10 | `GraphQL for PHP `_. 11 | 12 | You may choose which entities, fields, and associations in your object manager 13 | are available for querying through GraphQL. Filtering is provided for 14 | entities and for all associated collections. 15 | 16 | Pagination of collections is supported with 17 | `GraphQL's Complete Connection Model `_. 18 | 19 | .. role:: raw-html(raw) 20 | :format: html 21 | 22 | .. include:: footer.rst 23 | -------------------------------------------------------------------------------- /docs/attributes.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Attributes 3 | ========== 4 | 5 | Configuration of your entities for GraphQL is done with PHP attributes. 6 | There are three attributes and all options for each are covered in this 7 | document. 8 | 9 | The namespace for attributes is ``ApiSkeletons\Doctrine\ORM\GraphQL\Attribute``. 10 | It is recommended you alias this namespace in your entities as ``GraphQL``. 11 | 12 | A slightly complicated example: 13 | 14 | .. code-block:: php 15 | 16 | use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL 17 | use ApiSkeletons\Doctrine\ORM\GraphQL\Filter\Filters; 18 | 19 | #[GraphQL\Entity(description: 'Artist data', typeName: 'Artist')] 20 | #[GraphQL\Entity(group: 'admin', description: 'Artist data for admins')] 21 | class Artist 22 | { 23 | #[GraphQL\Field] 24 | #[GraphQL\Field(group: 'admin')] 25 | public $id; 26 | 27 | #[GraphQL\Field(description: 'Artist name', excludeFilters: [Filters::STARTSWITH])] 28 | #[GraphQL\Field(group: 'admin')] 29 | public $name; 30 | 31 | #[GraphQL\Association(excludeFilters: [Filters::CONTAINS, Filters::NEQ])] 32 | #[GraphQL\Association(group: 'admin', alias: 'shows')] 33 | public $performances; 34 | } 35 | 36 | 37 | Entity 38 | ====== 39 | 40 | Use this attribute on entities you want included in your graph. 41 | Optional parameters are: 42 | 43 | * ``description`` - A description of the ``Entity``. 44 | * ``excludeFilters`` - An array of Filters to exclude from available 45 | filters for all fields and associations in the entity. For instance, to 46 | exclude filters that use a ``like`` database query, set the following:: 47 | 48 | use ApiSkeletons\Doctrine\ORM\GraphQL\Filter\Filters; 49 | 50 | #[GraphQL\Entity(excludeFilters: [Filters::CONTAINS, Filters::STARTSWITH, Filters::ENDSWITH])] 51 | 52 | * ``group`` - You may have multiple GraphQL configurations organzied by 53 | ``group``. 54 | * ``includeFilters`` - An array of filters to include from available 55 | filters for all fields and associations in the entity. ``includeFilters`` 56 | and ``excludeFilters`` are mutually exclusive. 57 | * ``limit`` - A hard limit for all queries on this entity. Use this 58 | to prevent abuse of GraphQL. Defaults to global config ``limit``. 59 | * ``typeName`` - A name to reference the type for GraphQL. 60 | 61 | The following parameters are specific to the hydrator used to extract 62 | data from Doctrine entities. The hydrator library is 63 | `doctrine-laminas-hydrator `_ 64 | 65 | * ``byValue`` - Default is ``true``. When set to false the hydrator will 66 | extract values by reference. If you have getters and setters for all your 67 | fields then extracting by value will use those. Extracting by reference 68 | will reflect the entities and extract the values from the properties. 69 | More information here: 70 | `By Value and By Reference `_ 71 | 72 | 73 | Field 74 | ===== 75 | 76 | Use this attribute on fields (not associations) you want included 77 | in your graph. Optional parameters are: 78 | 79 | * ``alias`` - An alias to use as the GraphQL field name. 80 | * ``description`` - A description of the ``Field``. 81 | * ``excludeFilters`` - An array of filters to exclude from available 82 | filters for this field. Combined with ``excludeFilters`` of the entity. 83 | * ``group`` - You can have multiple GraphQL configurations organzied by 84 | ``group``. 85 | * ``includeFilters`` - An array of filters to include from available 86 | filters for the field. ``includeFilters`` 87 | and ``excludeFilters`` are mutually exclusive. 88 | * ``hydratorStrategy`` - A custom hydrator strategy class. 89 | Class must be injected into the HydratorFactory container. See `strategies `_ and `containers `_ 90 | * ``type`` - Override the GraphQL type name for the field. 91 | The custom type must be injected into the TypeContainer 92 | See `containers `_ 93 | 94 | .. code-block:: php 95 | 96 | // Handle a number field as a string 97 | 98 | #[GraphQL\Entity] 99 | class Artist 100 | { 101 | #[GraphQL\Field(type: 'customtype')] 102 | private int $number; 103 | } 104 | 105 | $driver = new Driver($this->getEntityManager()); 106 | $driver->get(TypeContainer::class)->set('customtype', fn() => Type::string()); 107 | 108 | 109 | Association 110 | =========== 111 | 112 | Used on any type of association including one to one, one to many, many to one, 113 | etc. Associations which are to one types will just include the entity they are 114 | associated with. Associations of the to many variety will become connections. 115 | 116 | * ``alias`` - An alias to use as the GraphQL field name. 117 | * ``description`` - A description of the ``Association``. 118 | * ``excludeFilters`` - An array of criteria to exclude from available 119 | filters for the association. Entity level ``excludeFilters`` are applied to 120 | associations. For instance, to exclude filters that use a ``like`` database 121 | query, set the following:: 122 | 123 | use ApiSkeletons\Doctrine\ORM\GraphQL\Filter\Filters; 124 | 125 | #[GraphQL\Association(excludeFilters: [Filters::CONTAINS, Filters::STARTSWITH, Filters::ENDSWITH])] 126 | 127 | * ``criteriaEventName`` - An event to fire when resolving this collection. 128 | Additional filters can be added to the criteria. An example of this use is for 129 | associations with soft deletes. 130 | * ``group`` - You can have multiple GraphQL configurations organzied by 131 | ``group``. 132 | * ``includeFilters`` - An array of filters to include from available 133 | filters for all fields in the association. ``includeFilters`` 134 | and ``excludeFilters`` are mutually exclusive. 135 | * ``limit`` - A limit for subqueries. This value overrides the Entity configured 136 | limit. 137 | * ``hydratorStrategy`` - A custom hydrator strategy class. 138 | Class must be injected into the HydratorFactory container. See `containers `_ 139 | 140 | .. role:: raw-html(raw) 141 | :format: html 142 | 143 | .. include:: footer.rst 144 | -------------------------------------------------------------------------------- /docs/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Skeletons/doctrine-orm-graphql/7a953cf60af7a1ad593d1030b806e197babb21c5/docs/banner.png -------------------------------------------------------------------------------- /docs/computed-fields.rst: -------------------------------------------------------------------------------- 1 | Computed Fields 2 | =============== 3 | 4 | You may add any computed field to an entity definition. This is done with the 5 | `EntityDefinition Event `_. 6 | 7 | Modify an Entity Definition 8 | --------------------------- 9 | 10 | You may modify the array used to define an entity type before it is created. 11 | This can be used for computed data. You must attach a listener 12 | before defining your GraphQL schema. 13 | 14 | Events of this type are named ``Entity::class . '.definition'`` and the event 15 | name cannot be modified. 16 | 17 | .. code-block:: php 18 | 19 | use ApiSkeletons\Doctrine\ORM\GraphQL\Driver; 20 | use ApiSkeletons\Doctrine\ORM\GraphQL\Event\EntityDefinition; 21 | use App\ORM\Entity\Artist; 22 | use App\ORM\Entity\Performance; 23 | use Doctrine\ORM\EntityManager; 24 | use GraphQL\Type\Definition\ResolveInfo; 25 | use League\Event\EventDispatcher; 26 | 27 | $driver = new Driver($entityManager); 28 | 29 | $driver->get(EventDispatcher::class)->subscribeTo( 30 | Artist::class . '.definition', 31 | static function (EntityDefinition $event) use ($driver): void { 32 | $definition = $event->getDefinition(); 33 | 34 | // In order to modify the fields you must resolve the closure 35 | $fields = $definition['fields'](); 36 | 37 | /** 38 | * Add a computed field to show the count of performances 39 | * This field will only be computed when it is requested specifically 40 | * in the query 41 | */ 42 | $fields['performanceCount'] = [ 43 | 'type' => Type::int(), 44 | 'description' => 'The count of performances for an Artist', 45 | 'resolve' => static function (Artist $objectValue, array $args, $context, ResolveInfo $info) use ($driver): int { 46 | $queryBuilder = $driver->get(EntityManager::class)->createQueryBuilder(); 47 | $queryBuilder 48 | ->select('COUNT(performance)') 49 | ->from(Performance::class, 'performance') 50 | ->andWhere($queryBuilder->expr('performance.artist', ':artistId')) 51 | ->setParameter('artistId', $objectValue->getId()); 52 | 53 | return $queryBuilder->getQuery()->getScalarResult(); 54 | }, 55 | ]; 56 | 57 | // Assign modified fields array to the ArrayObject 58 | $definition['fields'] = $fields; 59 | } 60 | ); 61 | 62 | A query for this computed field: 63 | 64 | .. code-block:: graphql 65 | 66 | query ArtistQueryWithComputedField($id: Int!) { 67 | artist(id: $id) { 68 | id 69 | name 70 | performanceCount 71 | } 72 | } 73 | 74 | 75 | .. role:: raw-html(raw) 76 | :format: html 77 | 78 | .. include:: footer.rst 79 | -------------------------------------------------------------------------------- /docs/conf-orig.py: -------------------------------------------------------------------------------- 1 | import sys, os 2 | from sphinx.highlighting import lexers 3 | from pygments.lexers.web import PhpLexer 4 | 5 | 6 | lexers['php'] = PhpLexer(startinline=True, linenos=1) 7 | lexers['php-annotations'] = PhpLexer(startinline=True, linenos=1) 8 | primary_domain = 'php' 9 | 10 | extensions = [] 11 | templates_path = ['_templates'] 12 | source_suffix = '.rst' 13 | master_doc = 'index' 14 | project = u'Doctrine ORM GraphQL' 15 | copyright = u'2024 API Skeletons' 16 | version = '9' 17 | html_title = "GraphQL Driver for Doctrine ORM" 18 | html_short_title = "Doctrine ORM GraphQL" 19 | html_favicon = 'favicon.ico' 20 | 21 | exclude_patterns = ['_build'] 22 | html_static_path = ['_static'] 23 | 24 | ##### Guzzle sphinx theme 25 | 26 | import guzzle_sphinx_theme 27 | html_translator_class = 'guzzle_sphinx_theme.HTMLTranslator' 28 | html_theme_path = guzzle_sphinx_theme.html_theme_path() 29 | html_theme = 'guzzle_sphinx_theme' 30 | 31 | # Custom sidebar templates, maps document names to template names. 32 | html_sidebars = { 33 | '**': ['logo-text.html', 'globaltoc.html', 'searchbox.html'] 34 | } 35 | 36 | # Register the theme as an extension to generate a sitemap.xml 37 | extensions.append("guzzle_sphinx_theme") 38 | 39 | # Guzzle theme options (see theme.conf for more information) 40 | html_theme_options = { 41 | 42 | # Set the path to a special layout to include for the homepage 43 | # "index_template": "homepage.html", 44 | 45 | # Allow a separate homepage from the master_doc 46 | # homepage = index 47 | 48 | # Set the name of the project to appear in the nav menu 49 | # "project_nav_name": "Guzzle", 50 | 51 | # Set your Disqus short name to enable comments 52 | # "disqus_comments_shortname": "my_disqus_comments_short_name", 53 | 54 | # Set you GA account ID to enable tracking 55 | # "google_analytics_account": "my_ga_account", 56 | 57 | # Path to a touch icon 58 | # "touch_icon": "", 59 | 60 | # Specify a base_url used to generate sitemap.xml links. If not 61 | # specified, then no sitemap will be built. 62 | "base_url": "https://doctrine-orm-graphql.apiskeletons.dev" 63 | 64 | # Allow the "Table of Contents" page to be defined separately from "master_doc" 65 | # tocpage = Contents 66 | 67 | # Allow the project link to be overriden to a custom URL. 68 | # projectlink = http://myproject.url 69 | } 70 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import sys, os 2 | from sphinx.highlighting import lexers 3 | from pygments.lexers.web import PhpLexer 4 | 5 | lexers['php'] = PhpLexer(startinline=True, linenos=0) 6 | lexers['php-annotations'] = PhpLexer(startinline=True, linenos=0) 7 | primary_domain = 'php' 8 | 9 | extensions = [] 10 | templates_path = ['_templates'] 11 | source_suffix = '.rst' 12 | master_doc = 'index' 13 | project = u'Doctrine ORM GraphQL' 14 | copyright = u'2024 API Skeletons' 15 | version = '9' 16 | html_title = "GraphQL Driver for Doctrine ORM" 17 | html_short_title = "Doctrine ORM GraphQL" 18 | html_favicon = 'favicon.ico' 19 | 20 | exclude_patterns = ['_build'] 21 | html_static_path = ['_static'] 22 | html_css_files = [ 23 | 'styles.css', 24 | ] 25 | 26 | ##### Guzzle sphinx theme 27 | 28 | import guzzle_sphinx_theme 29 | html_translator_class = 'guzzle_sphinx_theme.HTMLTranslator' 30 | html_theme_path = guzzle_sphinx_theme.html_theme_path() 31 | html_theme = 'guzzle_sphinx_theme' 32 | 33 | # Custom sidebar templates, maps document names to template names. 34 | html_sidebars = { 35 | '**': ['logo-text.html', 'globaltoc.html', 'searchbox.html'] 36 | } 37 | 38 | # Register the theme as an extension to generate a sitemap.xml 39 | extensions.append("guzzle_sphinx_theme") 40 | 41 | # Guzzle theme options (see theme.conf for more information) 42 | html_theme_options = { 43 | 44 | # Set the path to a special layout to include for the homepage 45 | # "index_template": "homepage.html", 46 | 47 | # Allow a separate homepage from the master_doc 48 | # homepage = index 49 | 50 | # Set the name of the project to appear in the nav menu 51 | # "project_nav_name": "Guzzle", 52 | 53 | # Set your Disqus short name to enable comments 54 | # "disqus_comments_shortname": "my_disqus_comments_short_name", 55 | 56 | # Set you GA account ID to enable tracking 57 | # "google_analytics_account": "my_ga_account", 58 | 59 | # Path to a touch icon 60 | # "touch_icon": "", 61 | 62 | # Specify a base_url used to generate sitemap.xml links. If not 63 | # specified, then no sitemap will be built. 64 | "base_url": "https://doctrine-orm-graphql.apiskeletons.dev" 65 | 66 | # Allow the "Table of Contents" page to be defined separately from "master_doc" 67 | # tocpage = Contents 68 | 69 | # Allow the project link to be overriden to a custom URL. 70 | # projectlink = http://myproject.url 71 | } 72 | -------------------------------------------------------------------------------- /docs/containers.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Containers 3 | ========== 4 | 5 | Internal to the classes used in this library, PSR-11 containers are used. 6 | You can set values in the containers using ``container->set($id, $value);``. 7 | **If a value already exists for the ``$id`` it will be overwritten.** 8 | 9 | Containers will execute any ``Closure`` found when getting from itself and pass 10 | the container to the closure as the only argument. This provides a basic 11 | method for factories. Once a factory has executed, the result will 12 | replace the factory so later requests will just get the composed object. 13 | 14 | There are two containers you should be aware of if you intend to extend this 15 | library. 16 | 17 | Type Container 18 | ============== 19 | 20 | The ``TypeContainer`` stores all the GraphQL types created or 21 | used in the library. If you want to specify your own type for a field you'll 22 | need to add your custom type to the container. 23 | 24 | .. code-block:: php 25 | 26 | use ApiSkeletons\Doctrine\ORM\GraphQL\Driver; 27 | use ApiSkeletons\Doctrine\ORM\GraphQL\Type\TypeContainer; 28 | use GraphQL\Type\Definition\Type; 29 | 30 | $driver = new Driver($this->getEntityManager()); 31 | $driver->get(TypeContainer::class) 32 | ->set('customtype', fn() => Type::string()); 33 | 34 | 35 | Custom Types 36 | ------------ 37 | 38 | For instance, if your schema has a ``timestamp`` type, that data type is not suppored 39 | by default in this library. But adding the type is just a matter of creating a 40 | new Timestamp type (modifying the DateTime class is uncomplicated) then adding the 41 | type to the type manager. 42 | 43 | .. code-block:: php 44 | 45 | $driver->get(TypeContainer::class) 46 | ->set('timestamp', fn() => new Type\Timestamp()); 47 | 48 | 49 | Hydrator Container 50 | ================== 51 | 52 | The ``HydratorContainer`` stores hydrator strategies and all the generated hydrators. 53 | Custom HydratorStrategies can be added to the container. 54 | 55 | .. code-block:: php 56 | 57 | use ApiSkeletons\Doctrine\ORM\GraphQL\Driver; 58 | use ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\HydratorContainer; 59 | 60 | $driver = new Driver($this->getEntityManager()); 61 | $driver->get(HydratorContainer::class) 62 | ->set('customstrategy', fn() => new CustomStrategy()); 63 | 64 | 65 | .. role:: raw-html(raw) 66 | :format: html 67 | 68 | .. include:: footer.rst 69 | -------------------------------------------------------------------------------- /docs/custom-doctrine-types.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | Custom Doctrine Types 3 | ===================== 4 | 5 | To implement non-standard Doctrine types as GraphQL types, 6 | you must implement ``GraphQL\Type\Definition\ScalarType`` and add it to the 7 | ``TypeContainer``. 8 | 9 | This example implements a Uuid type for ``ramsey/uuid-doctrine``. 10 | 11 | .. code-block:: php 12 | 13 | use GraphQL\Error\Error; 14 | use GraphQL\Language\AST\Node as ASTNode; 15 | use GraphQL\Language\AST\StringValueNode; 16 | use GraphQL\Type\Definition\ScalarType; 17 | use Ramsey\Uuid\Uuid as RamseyUuid; 18 | use Ramsey\Uuid\UuidInterface; 19 | 20 | use function preg_match; 21 | 22 | /** 23 | * This class is used to create a Uuid type 24 | */ 25 | class Uuid extends ScalarType 26 | { 27 | // phpcs:disable SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingAnyTypeHint 28 | public string|null $description = 'A universally unique identifier.'; 29 | 30 | public function parseLiteral(ASTNode $valueNode, array|null $variables = null): string 31 | { 32 | if (! $valueNode instanceof StringValueNode) { 33 | throw new Error('Query error: Uuid can only parse strings got: ' . $valueNode->kind, $valueNode); 34 | } 35 | 36 | return $this->parseValue($valueNode->value); 37 | } 38 | 39 | public function parseValue(mixed $value): UuidInterface|null 40 | { 41 | if ($value instanceof UuidInterface) { 42 | return $value; 43 | } 44 | 45 | if (! preg_match('/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/', $value)) { 46 | throw new Error('Uuid is invalid.'); 47 | } 48 | 49 | return RamseyUuid::fromString($value); 50 | } 51 | 52 | public function serialize(mixed $value): string|null 53 | { 54 | if ($value instanceof UuidInterface) { 55 | return $value->toString(); 56 | } 57 | 58 | return $value; 59 | } 60 | } 61 | 62 | Then add that type to the type container 63 | 64 | .. code-block:: php 65 | 66 | use ApiSkeletons\Doctrine\ORM\GraphQL\Type\TypeContainer; 67 | 68 | $driver->get(TypeContainer::class)->set('uuid', static fn () => new Uuid(); 69 | 70 | 71 | .. role:: raw-html(raw) 72 | :format: html 73 | 74 | .. include:: footer.rst 75 | -------------------------------------------------------------------------------- /docs/driver.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | The Driver class 3 | ================ 4 | 5 | The Driver class is the gateway to much of the functionality of this library. 6 | It has many options and top-level functions, detailed here. 7 | 8 | 9 | Creating a Driver with all config options 10 | ========================================= 11 | 12 | .. code-block:: php 13 | 14 | use ApiSkeletons\Doctrine\ORM\GraphQL\Config; 15 | use ApiSkeletons\Doctrine\ORM\GraphQL\Driver; 16 | use ApiSkeletons\Doctrine\ORM\GraphQL\Filter\Filters; 17 | 18 | $driver = new Driver($entityManager, new Config[ 19 | 'entityPrefix' => 'App\\ORM\\Entity\\', 20 | 'group' => 'customGroup', 21 | 'groupSuffix' => 'customGroupSuffix', 22 | 'globalEnable' => true, 23 | 'ignoreFields' => ['password'], 24 | 'globalByValue' => true, 25 | 'limit' => 500, 26 | 'sortFields' => true, 27 | 'useHydratorCache' => true, 28 | 'excludeFilters' => [Filters::LIKE], 29 | ]); 30 | 31 | 32 | Config 33 | ====== 34 | 35 | The ``Driver`` takes a second, optional, argument of type 36 | ``ApiSkeletons\Doctrine\ORM\GraphQL\Config``. The constructor of ``Config`` takes 37 | an array parameter. 38 | 39 | The parameter options are: 40 | 41 | 42 | entityPrefix 43 | ------------ 44 | 45 | This is a common namespace prefix for all entities in a group. When specified, 46 | the ``entityPrefix`` such as, 'App\\ORM\\Entity\\', will be stripped from driver name. So 47 | ``App_ORM_Entity_Artist_groupName`` 48 | becomes 49 | ``Artist_groupName`` 50 | See also ``groupSuffix`` 51 | 52 | 53 | excludeFilters 54 | -------------- 55 | 56 | An array of filters to exclude from all available filters for all fields 57 | and associations for all entities. 58 | 59 | 60 | group 61 | ----- 62 | 63 | Each attribute has an optional ``group`` parameter that allows 64 | for multiple configurations within the entities. Specify the group in the 65 | ``Config`` to load only those attributes with the same ``group``. 66 | If no ``group`` is specified the group value is ``default``. 67 | 68 | 69 | groupSuffix 70 | ----------- 71 | 72 | By default, the group name is appended to GraphQL types. You may specify 73 | a different suffix or an empty suffix. When used in combination with 74 | ``entityPrefix`` your type names can be changed from 75 | ``App_ORM_Entity_Artist_groupname`` 76 | to 77 | ``Artist`` 78 | 79 | 80 | globalEnable 81 | ------------ 82 | 83 | When set to true, all fields and all associations will be 84 | enabled. This is best used as a development setting when 85 | the entities are subject to change. Really. 86 | 87 | 88 | ignoreFields 89 | ------------ 90 | 91 | When ``globalEnable`` is set to true, this array of field and association names 92 | will be excluded from the schema. For instance ``['password']`` is a good choice 93 | to ignore globally. 94 | 95 | 96 | globalByValue 97 | ------------- 98 | 99 | This overrides the ``byValue`` entity attribute globally. When set to true 100 | all hydrators will extract by value. When set to false all hydrators will 101 | extract by reference. When not set the individual entity attribute value 102 | is used and that is, by default, extract by value. 103 | 104 | 105 | limit 106 | ----- 107 | 108 | A hard limit for all queries throughout the entities. Use this 109 | to prevent abuse of GraphQL. Default is 1000. 110 | 111 | 112 | sortFields 113 | ---------- 114 | 115 | When entity types are created, and after the definition event, 116 | the fields will be sorted alphabetically when set to true. 117 | This can aid reading of the documentation created by GraphQL. 118 | 119 | 120 | useHydratorCache 121 | ---------------- 122 | 123 | When set to true hydrator results will be cached for 124 | the duration of the request thereby saving possible multiple extracts for 125 | the same entity. Default is ``false`` 126 | 127 | 128 | Functions 129 | ========= 130 | 131 | completeConnection() 132 | -------------------- 133 | 134 | This is a short cut to using connection(), pagination(), resolve(), and filter(). 135 | There are three parameters: 136 | 137 | 1. Doctrine entity class name, required, 138 | 2. entityDefinitionEvent name, optional. 139 | 3. queryBuilderEvent name, optional. 140 | 141 | .. code-block:: php 142 | 143 | use ApiSkeletons\Doctrine\ORM\GraphQL\Driver; 144 | 145 | $driver = new Driver($this->getEntityManager()); 146 | 147 | $schema = new Schema([ 148 | 'query' => new ObjectType([ 149 | 'name' => 'query', 150 | 'fields' => [ 151 | 'artists' => $driver->completeConnection(Artist::class), 152 | ], 153 | ]), 154 | ]); 155 | 156 | 157 | connection(), pagination(), and resolve() 158 | ----------------------------------------- 159 | 160 | The ``connection`` function returns a wrapper for an entity type. This wrapper, 161 | in combination with the ``resolve`` and ``pagination`` functions, implements the 162 | `GraphQL Complete Connection Model `_. 163 | You may pass a second parameter to the ``connection`` function to specify the 164 | custom event name to fire for the entity definition event. 165 | 166 | 167 | .. code-block:: php 168 | 169 | use ApiSkeletons\Doctrine\ORM\GraphQL\Driver; 170 | 171 | $driver = new Driver($this->getEntityManager()); 172 | 173 | $schema = new Schema([ 174 | 'query' => new ObjectType([ 175 | 'name' => 'query', 176 | 'fields' => [ 177 | 'artists' => [ 178 | 'type' => $driver->connection(Artist::class), 179 | 'args' => [ 180 | 'pagination' => $driver->pagination(), 181 | ], 182 | 'resolve' => $driver->resolve(Artist::class), 183 | ], 184 | ], 185 | ]), 186 | ]); 187 | 188 | 189 | filter() 190 | -------- 191 | 192 | Based on the attribute configuration of an entity, this function adds a 193 | ``filter`` argument to a connection. See `filters `_ for a list of 194 | available filters per field. The args field must be ``filter``. 195 | 196 | Filters are applied to a ``connection``. It is also possible to use them ad-hoc 197 | as detailed in `tips `_. 198 | 199 | .. code-block:: php 200 | 201 | 'args' => [ 202 | 'pagination' => $driver->pagination(), 203 | 'filter' => $driver->filter(Artist::class), 204 | ], 205 | 206 | 207 | input() 208 | ------- 209 | 210 | This function creates an InputObjectType for the given entity. There are three 211 | parameters: The entity class name, an array of required fields, and an array 212 | of optional fields. 213 | 214 | 215 | type() 216 | ------ 217 | 218 | This function returns GraphQL types for all Doctrine types, any custom types, 219 | and Doctrine entity types. 220 | 221 | There are two type containers: ``TypeContainer`` and ``EntityTypeContainer``. 222 | Types from each of these containers are returned from this `type()` function. 223 | 224 | See `types `_ for details on custom types and using the ``TypeContainer``. 225 | 226 | The ``EntityTypeContainer`` is used only for Doctrine entities and is populated 227 | though the `metadata `_. This class is used internally for generating ``ObjectType`` types for entities. 228 | 229 | Though a ``connection`` is a type, it is not 230 | available through this function. Use the ``connection`` function of the Driver. 231 | 232 | 233 | .. role:: raw-html(raw) 234 | :format: html 235 | 236 | .. include:: footer.rst 237 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Skeletons/doctrine-orm-graphql/7a953cf60af7a1ad593d1030b806e197babb21c5/docs/favicon.ico -------------------------------------------------------------------------------- /docs/footer.rst: -------------------------------------------------------------------------------- 1 | 2 | ---------- 3 | 4 | This is documentation for 5 | `API-Skeletons/doctrine-orm-graphql `_. 6 | Please add your ★ star to the project. 7 | 8 | Authored by `API Skeletons `_. 9 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ==================================== 2 | GraphQL Type Driver for Doctrine ORM 3 | ==================================== 4 | 5 | .. image:: banner.png 6 | :align: center 7 | :scale: 25 % 8 | 9 | This is the documentation for 10 | `API-Skeletons/doctrine-orm-graphql `_ 11 | 12 | This project builds GraphQL types, filters, and resolvers for Doctrine entities. To select which 13 | entities, fields, and associations are available for querying via GraphQL, 14 | PHP attributes are used. 15 | 16 | Other GraphQL libraries for Doctrine ORM are available. 17 | Some of these such as `overblog/graphql-bundle `_ 18 | and `API Platform `_ are integrations into frameworks. But all of these libraries 19 | use the same underlying library, `webonyx/graphql-php `_ and that library 20 | has its own way of doing things. This library is a driver for that library and together they are framework agnostic. 21 | 22 | The goal of this project is to make creating GraphQL types simple and uncomplicated, but as 23 | you'll see, there's a lot of customizable power built in too. 24 | 25 | .. toctree:: 26 | 27 | :caption: Table of Contents 28 | 29 | install 30 | just-the-basics 31 | queries 32 | attributes 33 | driver 34 | mutations 35 | types 36 | computed-fields 37 | events 38 | extending-entity-types 39 | containers 40 | metadata 41 | strategies 42 | tips 43 | custom-doctrine-types 44 | versions 45 | upgrade 46 | about 47 | 48 | 49 | .. role:: raw-html(raw) 50 | :format: html 51 | 52 | .. include:: footer.rst 53 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Install 3 | ======= 4 | 5 | Installation of this module uses composer. For composer documentation, please 6 | refer to `getcomposer.org `_ :: 7 | 8 | $ composer require api-skeletons/doctrine-orm-graphql 9 | 10 | .. role:: raw-html(raw) 11 | :format: html 12 | 13 | .. include:: footer.rst 14 | -------------------------------------------------------------------------------- /docs/just-the-basics.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Just The Basics 3 | =============== 4 | 5 | You will need a Doctrine object manager with entities configured with 6 | appropriate associations throughout. Support for ad-hoc joins between 7 | entities is not supported (but you can use the EntityDefinition event 8 | to add a custom type to an entity type). 9 | Your Doctrine metadata will map the associations in GraphQL. 10 | 11 | There are some `config options `_ available but they are 12 | all optional. 13 | 14 | The first step is to add attributes to your entities. 15 | Attributes are stored in the namespace 16 | ``ApiSkeletons\Doctrine\ORM\GraphQL\Attribute`` and there are attributes for 17 | ``Entity``, ``Field``, and ``Association``. Use the appropriate attribute on 18 | each element you want to be queryable from GraphQL. 19 | 20 | .. code-block:: php 21 | 22 | use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL; 23 | 24 | #[GraphQL\Entity] 25 | class Artist 26 | { 27 | #[GraphQL\Field] 28 | private $id; 29 | 30 | #[GraphQL\Field] 31 | private $name; 32 | } 33 | 34 | That's the minimum configuration required. Next, create your driver using your 35 | entity manager 36 | 37 | .. code-block:: php 38 | 39 | use ApiSkeletons\Doctrine\ORM\GraphQL\Driver; 40 | 41 | $driver = new Driver($entityManager); 42 | 43 | Then configure your GraphQL schema. In this section we'll create 44 | a connection for the entity, filters for the entity, and a resolver. 45 | 46 | .. code-block:: php 47 | 48 | use GraphQL\Type\Definition\ObjectType; 49 | use GraphQL\Type\Definition\Type; 50 | use GraphQL\Type\Schema; 51 | 52 | $schema = new Schema([ 53 | 'query' => new ObjectType([ 54 | 'name' => 'query', 55 | 'fields' => [ 56 | 'artists' => [ 57 | 'type' => $driver->connection(Artist::class), 58 | 'args' => [ 59 | 'filter' => $driver->filter(Artist::class), 60 | 'pagination' => $driver->pagination(), 61 | ], 62 | 'resolve' => $driver->resolve(Artist::class), 63 | ], 64 | ], 65 | ]), 66 | ]); 67 | 68 | Now, using the schema, you can start making GraphQL queries 69 | 70 | .. code-block:: php 71 | 72 | use GraphQL\GraphQL; 73 | 74 | $query = ' 75 | { 76 | artists { 77 | edges { 78 | node { 79 | id 80 | name 81 | } 82 | } 83 | } 84 | } 85 | '; 86 | 87 | $result = GraphQL::executeQuery($schema, $query); 88 | 89 | If you want to add an association you must set attributes on the target entity. 90 | In the following example, the Artist entity has a one-to-many relationship with 91 | Performance and we want to make deeper queries from Artist to Performance. 92 | 93 | .. code-block:: php 94 | 95 | use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL; 96 | 97 | #[GraphQL\Entity] 98 | class Artist 99 | { 100 | #[GraphQL\Field] 101 | private $id; 102 | 103 | #[GraphQL\Field] 104 | private $name; 105 | 106 | #[GraphQL\Association] 107 | private $performances; 108 | } 109 | 110 | #[GraphQL\Entity] 111 | class Performance 112 | { 113 | #[GraphQL\Field] 114 | private $id; 115 | 116 | #[GraphQL\Field] 117 | private $venue; 118 | } 119 | 120 | Using the same Schema configuration as above, with the new Performance 121 | attributes, a query of performances is now possible: 122 | 123 | .. code-block:: php 124 | 125 | use GraphQL\GraphQL; 126 | 127 | $query = ' 128 | { 129 | artists { 130 | edges { 131 | node { 132 | id 133 | name 134 | performances { 135 | edges { 136 | node { 137 | id 138 | venue 139 | } 140 | } 141 | } 142 | } 143 | } 144 | } 145 | } 146 | '; 147 | 148 | $result = GraphQL::executeQuery($schema, $query); 149 | 150 | Keep reading to learn how to create 151 | `multiple attribute groups `_, 152 | `extract entities by reference or by value `_, 153 | `cache attribute metadata `_, 154 | `implement custom types `_, 155 | `alias fields `_, 156 | and more. 157 | 158 | 159 | .. role:: raw-html(raw) 160 | :format: html 161 | 162 | .. include:: footer.rst 163 | -------------------------------------------------------------------------------- /docs/metadata.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Metadata 3 | ======== 4 | 5 | This library uses metadata that can be modified with the 6 | `BuildMetadata event `_. 7 | Modifying the metadata is an advanced feature. 8 | 9 | The metadata is an array with a key for each enabled entity class name. 10 | See this unit test 11 | https://github.com/API-Skeletons/doctrine-orm-graphql/blob/12.0.x/test/Feature/Metadata/CachingTest.php 12 | 13 | Caching Metadata 14 | ================ 15 | 16 | The process of attributing your entities results in an array of metadata that 17 | is used internal to this library. If you have a very large number of 18 | attributed entities it may be faster to cache your metadata instead of 19 | rebuilding it with each request. 20 | 21 | .. code-block:: php 22 | 23 | use ApiSkeletons\Doctrine\ORM\GraphQL\Driver; 24 | use ApiSkeletons\Doctrine\ORM\GraphQL\Metadata; 25 | 26 | $metadata = $cache->get('GraphQLMetadata'); 27 | 28 | if (! $metadata) { 29 | $driver = new Driver($entityManager); 30 | 31 | $metadata = $driver->get('metadata'); 32 | $cache->set('GraphQLMetadata', $metadata->getArrayCopy()); 33 | } else { 34 | // The second parameter is the Config object 35 | $driver = new Driver($entityManager, null, $metadata); 36 | } 37 | 38 | .. role:: raw-html(raw) 39 | :format: html 40 | 41 | .. include:: footer.rst 42 | 43 | -------------------------------------------------------------------------------- /docs/mutations.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Running Mutations 3 | ================= 4 | 5 | Mutations modify data in your Doctrine ORM. They are defined as such: 6 | 7 | .. code-block:: php 8 | 9 | $schema = new Schema([ 10 | 'mutation' => new ObjectType([ 11 | 'name' => 'mutation', 12 | 'fields' => [ 13 | 'mutationName' => [ 14 | 'type' => $driver->type(Artist::class), 15 | 'args' => [ 16 | 'id' => Type::nonNull(Type::id()), 17 | 'input' => Type::nonNull($driver->input(Artist::class, ['name'])), 18 | ], 19 | 'resolve' => function ($root, $args) use ($driver): User { 20 | $artist = $driver->get(EntityManager::class) 21 | ->getRepository(Artist::class) 22 | ->find($args['id']); 23 | 24 | $artist->setName($args['input']['name']); 25 | $driver->get(EntityManager::class)->flush(); 26 | 27 | return $artist; 28 | }, 29 | ], 30 | ], 31 | ]), 32 | ]); 33 | 34 | You can define multiple mutations under the ``fields`` array. The ``type`` is 35 | the GraphQL type of the entity you're processing and will return. The ``args`` array in this 36 | example has a traditional argument and an ``input`` argument. The ``input`` 37 | argument is created using the driver ``$driver->input(Entity::class)`` method and 38 | has two optional arguments. The ``resolve`` method passes the ``args`` to 39 | a function that will do the work. In this example that function returns an 40 | ``Artist`` entity thereby allowing a query on the result. 41 | 42 | 43 | Calling Mutations 44 | ================= 45 | 46 | .. code-block:: php 47 | 48 | $query = 'mutation MutationName($id: Int!, $name: String!) { 49 | mutationName(id: $id, input: { name: $name }) { 50 | id 51 | name 52 | } 53 | }'; 54 | 55 | To call a mutation you must prefix the request with ``mutation``. The mutation 56 | will then take input from the ``args`` array. The ``id`` and ``name`` in this 57 | mutation will return the new values from the mutated entity. 58 | 59 | 60 | Input Argument 61 | ============== 62 | 63 | The driver function ``$driver->input(Entity::class)`` will return an 64 | ``InputObjectType`` with all the fields set to nonNull, thereby making them 65 | required. Since this is rarely what is intended, there are two optional 66 | parameters to specify required and optional fields. 67 | 68 | .. code-block:: php 69 | 70 | $driver->input(Entity::class, ['requiredField'], ['optionalField']) 71 | 72 | In the above mutation example the ``name`` field is required and there are no 73 | optional fields, so the only field in the ``input`` args will be ``name``. 74 | The ``name`` input field will be typed according to its metadata configuration. 75 | 76 | Identifiers are excluded from the input field list because they should not be 77 | changed or added by a user. 78 | 79 | .. role:: raw-html(raw) 80 | :format: html 81 | 82 | .. include:: footer.rst 83 | -------------------------------------------------------------------------------- /docs/queries.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Running Queries 3 | =============== 4 | 5 | This section is intended for the developer who needs to write queries 6 | against an implementation of this repository. 7 | 8 | Queries are not special to this repository. The format of queries are 9 | exactly what GraphQL is spec'd out to be. 10 | 11 | Pagination of ``collections`` supports 12 | `GraphQL's Complete Connection Model `_. 13 | 14 | An example query: 15 | 16 | Fetch at most 100 performances in CA for each artist with 'Dead' in their name. 17 | 18 | .. code-block:: js 19 | 20 | { 21 | artists ( filter: { name: { contains: "Dead" } } ) { 22 | edges { 23 | node { 24 | name 25 | performances ( 26 | filter: { state: { eq: "CA" } } 27 | pagination: { first: 100 } 28 | ) { 29 | edges { 30 | node { 31 | performanceDate 32 | venue 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | 42 | Filters 43 | ======= 44 | 45 | For each field, which is not a reference to another entity, a colletion of 46 | filters exist. Given an entity which contains a `name` field you may directly 47 | filter the name using 48 | 49 | .. code-block:: js 50 | 51 | filter: { name: { eq: "Grateful Dead" } } 52 | 53 | You may only use each field's filter once per filter(). Should a child record 54 | have the same name as a parent it will share the filter names but filters are 55 | specific to the entity they filter upon. 56 | 57 | Provided Filters:: 58 | 59 | eq - Equals; same as name: value. DateTime not supported. See Between. 60 | neq - Not Equals 61 | gt - Greater Than 62 | lt - Less Than 63 | gte - Greater Than or Equal To 64 | lte - Less Than or Equal To 65 | in - Filter for values in an array 66 | notin - Filter for values not in an array 67 | between - Filter between `from` and `to` values. Good substitute for DateTime Equals. 68 | contains - Strings only. Similar to a Like query as `like '%value%'` 69 | startswith - Strings only. A like query from the beginning of the value `like 'value%'` 70 | endswith - Strings only. A like query from the end of the value `like '%value'` 71 | isnull - If `true` return results where the field is null. 72 | sort - Sort the result by this field. Value is 'asc' or 'desc' 73 | 74 | The format for using these filters is: 75 | 76 | .. code-block:: js 77 | 78 | filter: { name: { endswith: "Dead" } } 79 | 80 | For isnull the parameter is a boolean 81 | 82 | .. code-block:: js 83 | 84 | filter: { name: { isnull: false } } 85 | 86 | For in and notin an array of values is expected 87 | 88 | .. code-block:: js 89 | 90 | filter: { name: { in: ["Phish", "Legion of Mary"] } } 91 | 92 | For the between filter two parameters are necessary. This is very useful for 93 | date ranges and number queries. 94 | 95 | .. code-block:: js 96 | 97 | filter: { year: { between: { from: 1966 to: 1995 } } } 98 | 99 | 100 | To select a list of years 101 | 102 | .. code-block:: js 103 | 104 | { 105 | artists ( filter: { id: { eq: 2 } } ) { 106 | edges { 107 | node { 108 | performances ( filter: { year: { sort: "asc" } } ) { 109 | edges { 110 | node { 111 | year 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | 120 | 121 | All filters are **AND** filters. For **OR** support use multiple 122 | queries and aggregate them. 123 | 124 | 125 | Pagination 126 | ========== 127 | 128 | Pagination of collections supports 129 | `GraphQL's Complete Connection Model `_. 130 | 131 | A pagination argument is included with embedded collections but for top-level 132 | collections you must include the pagination argument yourself just as you do 133 | for filters. 134 | 135 | A complete query for all pagination data: 136 | 137 | .. code-block:: js 138 | 139 | { 140 | artists (pagination: {first: 10, after: "cursor"}) { 141 | totalCount 142 | pageInfo { 143 | endCursor 144 | hasNextPage 145 | } 146 | edges { 147 | cursor 148 | node { 149 | id 150 | } 151 | } 152 | } 153 | } 154 | 155 | Cursors are included with each edge. A cursor is a base64 encoded 156 | offset from the beginning of the result set. ``base64_encode('0');`` is 157 | ``MA==`` to use when creating a paginated query. 158 | 159 | 160 | Two pairs of parameters work with the query: 161 | 162 | * ``first`` and ``after`` 163 | * ``last`` and ``before`` 164 | 165 | * ``first`` corresponds to the items per page starting from the beginning; 166 | * ``after`` corresponds to the cursor from which the items are returned. 167 | * ``last`` corresponds to the items per page starting from the end; 168 | * ``before`` corresponds to the cursor from which the items are returned, from a backwards point of view. 169 | 170 | To get the first page specify the number of edges 171 | 172 | .. code-block:: js 173 | 174 | { 175 | artists (pagination: { first: 10 }) { 176 | } 177 | } 178 | 179 | To get the next page, you would add the endCursor from the current page as the after parameter. 180 | 181 | .. code-block:: js 182 | 183 | { 184 | artists (pagination: { first: 10, after: "endCursor" }) { 185 | } 186 | } 187 | 188 | For the previous page, you would add the startCursor from the current page as the before parameter. 189 | 190 | .. code-block:: js 191 | 192 | { 193 | offers (pagination: { last: 10, before: "startCursor" }) { 194 | } 195 | } 196 | 197 | .. role:: raw-html(raw) 198 | :format: html 199 | 200 | .. include:: footer.rst 201 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | guzzle_sphinx_theme 2 | -------------------------------------------------------------------------------- /docs/strategies.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | Hydrator Strategies 3 | =================== 4 | 5 | Some hydrator strategies are supplied with this library. You may also add your own hydrator 6 | strategies. 7 | 8 | Included strategies are in the namespace ``ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\Strategy`` 9 | 10 | FieldDefault 11 | ============ 12 | 13 | This strategy is applied to most field values. It will return the exact value of the field. 14 | 15 | 16 | ToInteger 17 | ========= 18 | 19 | This strategy will convert the field value to an integer to be handled as an integer internal to PHP. 20 | 21 | 22 | ToFloat 23 | ======= 24 | 25 | Similar to ``ToInteger``, this will convert the field value to a float to be handled as a float internal to PHP. 26 | 27 | 28 | ToBoolean 29 | ========= 30 | 31 | Similar to ``ToInteger``, this will convert the field value to a boolean to be handled as a boolean internal to PHP. 32 | 33 | 34 | Add a custom hydrator strategy 35 | ============================== 36 | 37 | To add a custom hydrator strategy, create a class that implements the interface 38 | ``Laminas\Hydrator\Strategy\StrategyInterface``. Add the class to the 39 | hydrator strategy container after creating the driver. 40 | 41 | .. code-block:: php 42 | 43 | use ApiSkeletons\Doctrine\ORM\GraphQL\Driver; 44 | use ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\HydratorContainer; 45 | use App\GraphQL\Hydrator\Strategy\S3Url; 46 | 47 | $driver = new Driver($entityManager); 48 | 49 | $driver->get(HydratorContainer::class) 50 | ->set(S3Url::class, static fn () => new S3Url()); 51 | 52 | The S3Url class would look something like this: 53 | 54 | .. code-block:: php 55 | 56 | namespace App\GraphQL\Hydrator\Strategy; 57 | 58 | use Illuminate\Support\Facades\Storage; 59 | use Laminas\Hydrator\Strategy\StrategyInterface; 60 | 61 | /** 62 | * Resolve the token to an S3 url 63 | */ 64 | class S3Url implements 65 | StrategyInterface 66 | { 67 | public function extract(mixed $value, object|null $object = null): mixed 68 | { 69 | if (! $value) { 70 | return $value; 71 | } 72 | 73 | return Storage::disk('s3')->url($value); 74 | } 75 | 76 | /** 77 | * This library does not hydrate using the hydrator but this method is required 78 | * @param mixed[]|null $data 79 | */ 80 | public function hydrate(mixed $value, array|null $data): mixed 81 | { 82 | return $value; 83 | } 84 | } 85 | 86 | Then add the hydratorStrategy to the entity field you wish to custom extract. 87 | 88 | .. code-block:: php 89 | 90 | #[GraphQL\Field(hydratorStrategy: S3Url::class)] 91 | #[ORM\Column(type: "text", nullable: true)] 92 | public $favicon; 93 | 94 | .. role:: raw-html(raw) 95 | :format: html 96 | 97 | .. include:: footer.rst 98 | -------------------------------------------------------------------------------- /docs/tips.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Tips and Tricks 3 | =============== 4 | 5 | Here are tips for using this library in more edge-case ways. 6 | 7 | 8 | Serve a CSV Field as a GraphQL Array 9 | ==================================== 10 | 11 | If you have a field in your entity that is a CSV string and you want to 12 | convert it to a GraphQL array, you can use a custom hydrator strategy and custom type. 13 | 14 | Create a new hydrator strategy 15 | 16 | .. code-block:: php 17 | 18 | namespace App\GraphQL\Hydrator\Strategy; 19 | 20 | use Laminas\Hydrator\Strategy\StrategyInterface; 21 | 22 | use function explode; 23 | use function implode; 24 | 25 | class CsvString implements 26 | StrategyInterface 27 | { 28 | /** @return String[] */ 29 | public function extract(mixed $value, object|null $object = null): array 30 | { 31 | if (! $value) { 32 | return []; 33 | } 34 | 35 | return explode(',', (string) $value); 36 | } 37 | 38 | /** 39 | * StrategyInterface requires a hydrate method but this library does not 40 | * perform hydration of data; just extraction. 41 | * 42 | * @param mixed[]|null $data 43 | */ 44 | public function hydrate(mixed $value, array|null $data = null): mixed 45 | { 46 | if (! $value) { 47 | return ; 48 | } 49 | 50 | return implode(',', $value); 51 | } 52 | } 53 | 54 | Add the type and hydrator strategy to the field: 55 | 56 | .. code-block:: php 57 | 58 | use ApiSkeletons\Doctrine\ORM\GraphQL\Attribute as GraphQL; 59 | use App\GraphQL\Hydrator\Strategy\CsvString; 60 | 61 | #[GraphQL\Field(type: 'csvstring', hydratorStrategy: CsvString::class)] 62 | public string $csvField; 63 | 64 | Add the new type and hydrator strategy to the Driver: 65 | 66 | .. code-block:: php 67 | 68 | use ApiSkeletons\Doctrine\ORM\GraphQL\Hydrator\HydratorContainer; 69 | use ApiSkeletons\Doctrine\ORM\GraphQL\Type\TypeContainer; 70 | use App\GraphQL\Hydrator\Strategy\CsvString; 71 | 72 | $driver->get(HydratorContainer::class)->set(CsvString::class, fn() => new CsvString()); 73 | $driver->get(TypeContainer::class)->set('csvstring', fn() => Type::listOf(Type::string())); 74 | 75 | 76 | Filters for Scalar Queries 77 | ========================== 78 | 79 | The ``$driver->filter(Entity::class)`` filter may be used outside of a 80 | connection. For instance, to create a Doctrine query for the average 81 | of a field you can construct your query like this: 82 | 83 | .. code-block:: php 84 | 85 | use ApiSkeletons\Doctrine\ORM\GraphQL\Filter\QueryBuilder as FilterQueryBuilder; 86 | use ApiSkeletons\Doctrine\ORM\GraphQL\Types\Entity\EntityTypeContainer; 87 | use Doctrine\ORM\EntityManager; 88 | use GraphQL\Type\Definition\Type; 89 | 90 | 'average' => [ 91 | 'type' => Type::float(), 92 | 'args' => [ 93 | 'filter' => $driver->filter(Entity::class), 94 | ], 95 | 'resolve' => function ($root, array $args, $context, ResolveInfo $info) use ($driver) { 96 | $entity = $driver->get(EntityTypeContainer::class)->get(Entity::class) 97 | 98 | $filterQueryBuilder = new FilterQueryBuilder(); 99 | 100 | $queryBuilder = $driver->get(EntityManager::class) 101 | ->createQueryBuilder(); 102 | $queryBuilder 103 | ->select('AVG(entity.fieldName)') 104 | ->from(Entity::class, 'entity'); 105 | 106 | // The apply method requires a third parameter of the entity 107 | $filterQueryBuilder->apply($args['filter'], $queryBuilder, $entity); 108 | 109 | return $queryBuilder->getQuery()->getScalarResult(); 110 | } 111 | ], 112 | 113 | 114 | Shared Type Container 115 | ===================== 116 | 117 | If you have more than one driver and it uses a different group, 118 | and you use both drivers together in a single schema, 119 | you will have type collisions with the Pagination and PageInfo types. 120 | The reason a collision occurs is because the 121 | GraphQL specification defines PageInfo as a `Reserved Type `_. 122 | 123 | The problem is each driver will have its own definition for 124 | these types and they are not identical at runtime in PHP. 125 | To work around this you must use a shared type container: 126 | 127 | .. code-block:: php 128 | 129 | use ApiSkeletons\Doctrine\ORM\GraphQL\Type\TypeContainer; 130 | 131 | $driver1 = new Driver($entityManager, new Config(['group' => 'group1'])); 132 | $driver2 = new Driver($entityManager, new Config(['group' => 'group2'])); 133 | 134 | $driver2->set(TypeContainer::class, $driver1->get(TypeContainer::class)); 135 | 136 | .. role:: raw-html(raw) 137 | :format: html 138 | 139 | .. include:: footer.rst 140 | -------------------------------------------------------------------------------- /docs/types.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Data Types 3 | ========== 4 | 5 | `webonyx/graphql-php `_ 6 | includes the basic GraphQL types. 7 | 8 | This library has many other types that are primarily 9 | used to map Doctrine types to GraphQL types. 10 | 11 | Data Type Mappings 12 | ================== 13 | 14 | .. list-table:: Data Type Mappings 15 | :widths: 33 33 34 16 | :header-rows: 1 17 | 18 | * - GraphQL and Doctrine 19 | - PHP 20 | - Javascript 21 | * - bigint 22 | - string 23 | - integer or string 24 | * - blob 25 | - string (binary) 26 | - Base64 encoded string 27 | * - boolean 28 | - boolean 29 | - boolean 30 | * - date 31 | - DateTime 32 | - string as Y-m-d 33 | * - date_immutable 34 | - DateTimeImmutable 35 | - string as Y-m-d 36 | * - datetime 37 | - DateTime 38 | - ISO 8601 date string 39 | * - datetime_immutable 40 | - DateTimeImmutable 41 | - ISO 8601 date string 42 | * - datetimetz 43 | - DateTime 44 | - ISO 8601 date string 45 | * - datetimetz_immutable 46 | - DateTimeImmutable 47 | - ISO 8601 date string 48 | * - decimal 49 | - string 50 | - float 51 | * - float 52 | - float 53 | - float 54 | * - int & integer 55 | - integer 56 | - integer 57 | * - json 58 | - string 59 | - string of json 60 | * - simple_array 61 | - array of strings 62 | - array of strings 63 | * - smallint 64 | - integer 65 | - integer 66 | * - string 67 | - string 68 | - string 69 | * - text 70 | - string 71 | - string 72 | * - time 73 | - DateTime 74 | - string as H:i:s or H:i:s.u 75 | * - time_immutable 76 | - DateTimeImmutable 77 | - string as H:i:s or H:i:s.u 78 | 79 | See also `Doctrine Mapping Types `_. 80 | 81 | Using Types 82 | =========== 83 | 84 | You may use any of the above types freely such as a blob for an 85 | input type. 86 | 87 | .. code-block:: php 88 | 89 | use ApiSkeletons\Doctrine\ORM\GraphQL\Type\TypeContainer; 90 | 91 | $schema = new Schema([ 92 | 'mutation' => new ObjectType([ 93 | 'name' => 'mutation', 94 | 'fields' => [ 95 | 'uploadFile' => [ 96 | 'type' => $driver->type(ArtistFile::class), 97 | 'args' => [ 98 | 'file' => $driver->type('blob'), 99 | ], 100 | 'resolve' => function ($root, array $args, $context, ResolveInfo $info) use ($driver) { 101 | /** 102 | * $args['file'] will be sent base64 encoded then 103 | * unencoded in the PHP type so by the time it gets 104 | * here it is already an uploaded file 105 | */ 106 | 107 | // ...save to doctrine blob column 108 | }, 109 | ], 110 | ], 111 | ]), 112 | ]); 113 | 114 | 115 | Custom Types 116 | ============ 117 | 118 | If your schema has a ``timestamp`` type, that data type is not supported 119 | by this library. But adding the type is just a matter of creating a 120 | new Timestamp type extending ``GraphQL\Type\Definition\ScalarType`` then adding 121 | the type to the type container. 122 | 123 | .. code-block:: php 124 | 125 | $driver->get(TypeContainer::class) 126 | ->set('timestamp', fn() => new Timestamp()); 127 | 128 | See also `Serve a CSV Field as a GraphQL Array `_. 129 | 130 | .. role:: raw-html(raw) 131 | :format: html 132 | 133 | .. include:: footer.rst 134 | -------------------------------------------------------------------------------- /docs/upgrade.rst: -------------------------------------------------------------------------------- 1 | ============================== 2 | Upgrade from previous versions 3 | ============================== 4 | 5 | 9.x to 10.x 6 | =========== 7 | 8 | The ``$driver->connection()`` function no longer takes an ObjectType for an 9 | entity. Instead, just pass the entity class name. 10 | 11 | 12 | 8.1.3 doctrine-graphql 13 | ====================== 14 | 15 | This repository, ``api-skeletons/doctrine-orm-graphql`` is a continuation of 16 | ``api-skeletons/doctrine-graphql`` but there are some changes necessary to 17 | move from the old repository to this new one. 18 | 19 | 20 | Namespaces 21 | ---------- 22 | 23 | The old namespace was ``ApiSkeletons\Doctrine\GraphQL`` and the new namespace 24 | is ``ApiSkeletons\Doctrine\ORM\GraphQL``. This is the only change between 25 | the repositories that should affect you. 26 | 27 | The namespace change was made to be more technically correct (the best kind 28 | of correct) as each repository only supports ORM and does not support ODM. 29 | 30 | 31 | Documentation 32 | ------------- 33 | 34 | With the new repository the documentation was reviewed in whole and corrected 35 | where necessary. There is a new theme for the documentation, leaving the old ReadTheDocs default behind. And, though the documentation is still hosted by https://readthedocs.org it has been moved to a new 36 | domain: https://doctrine-orm-graphql.apiskeletons.dev 37 | 38 | 39 | What to do? 40 | ----------- 41 | 42 | As a user of the old repository version 8.1.3, change your namespaces to the 43 | new namespace then replace your composer require to ``api-skeletons/doctrine-orm-graphql ^8.1`` and you will be upgraded to the new repository version 8.1.4. 44 | -------------------------------------------------------------------------------- /docs/versions.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Versions and Event Manager Support 3 | ================================== 4 | 5 | The event manager used in this library is from `league/event `_. 6 | There are two supported versions of the event manager library by 7 | `The PHP League `_ and their API is very different. In this library, 8 | version 3 of `league/event` has always been used. Version 3 is a PSR-14 compliant event manager. 9 | 10 | However, `The PHP League `_ does not use the latest version of their own 11 | event manager in their `league/oauth2-server `_. Because of this 12 | old version requirement, it was not possible to install the ``league/oauth2-server`` library and this library in the 13 | same project. Version 11 of ``api-skeletons/doctrine-orm-graphql`` has regressive support for ``league/event`` 14 | by supporting version 2 of that library instead of version 3. Version 2 is not PSR-14 compliant. 15 | 16 | If you need to install ``league/oauth2-server`` and ``api-skeletons/doctrine-orm-graphql`` in the same project, 17 | you must use version 11 of this library. 18 | 19 | If you do not need to install ``league/oauth2-server`` and ``api-skeletons/doctrine-orm-graphql`` in the 20 | same project, you should use version 12 of this library. 21 | 22 | 23 | .. role:: raw-html(raw) 24 | :format: html 25 | 26 | .. include:: footer.rst 27 | -------------------------------------------------------------------------------- /src/Attribute/Association.php: -------------------------------------------------------------------------------- 1 | alias; 37 | } 38 | 39 | public function getLimit(): int|null 40 | { 41 | return $this->limit; 42 | } 43 | 44 | public function getGroup(): string 45 | { 46 | return $this->group; 47 | } 48 | 49 | public function getHydratorStrategy(): string|null 50 | { 51 | return $this->hydratorStrategy; 52 | } 53 | 54 | public function getDescription(): string|null 55 | { 56 | return $this->description; 57 | } 58 | 59 | public function getCriteriaEventName(): string|null 60 | { 61 | return $this->criteriaEventName; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Attribute/Entity.php: -------------------------------------------------------------------------------- 1 | group; 36 | } 37 | 38 | public function getByValue(): bool 39 | { 40 | return $this->byValue; 41 | } 42 | 43 | public function getLimit(): int 44 | { 45 | return $this->limit; 46 | } 47 | 48 | public function getDescription(): string|null 49 | { 50 | return $this->description; 51 | } 52 | 53 | public function getTypeName(): string|null 54 | { 55 | return $this->typeName; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Attribute/ExcludeFilters.php: -------------------------------------------------------------------------------- 1 | includeFilters) && count($this->excludeFilters)) { 32 | throw new Exception('includeFilters and excludeFilters are mutually exclusive.'); 33 | } 34 | 35 | if (count($this->includeFilters)) { 36 | $filters = array_udiff( 37 | Filters::cases(), 38 | $this->includeFilters, 39 | static function (Filters $a1, Filters $a2) { 40 | return $a1->value <=> $a2->value; 41 | }, 42 | ); 43 | } elseif (count($this->excludeFilters)) { 44 | $filters = array_uintersect( 45 | Filters::cases(), 46 | $this->excludeFilters, 47 | static function (Filters $a1, Filters $a2) { 48 | return $a1->value <=> $a2->value; 49 | }, 50 | ); 51 | } 52 | 53 | return $filters; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Attribute/Field.php: -------------------------------------------------------------------------------- 1 | alias; 36 | } 37 | 38 | public function getDescription(): string|null 39 | { 40 | return $this->description; 41 | } 42 | 43 | public function getGroup(): string 44 | { 45 | return $this->group; 46 | } 47 | 48 | public function getHydratorStrategy(): string|null 49 | { 50 | return $this->hydratorStrategy; 51 | } 52 | 53 | public function getType(): string|null 54 | { 55 | return $this->type; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Buildable.php: -------------------------------------------------------------------------------- 1 | 'default', 88 | 'groupSuffix' => null, 89 | 'useHydratorCache' => false, 90 | 'limit' => 1000, 91 | 'globalEnable' => false, 92 | 'ignoreFields' => [], 93 | 'globalByValue' => null, 94 | 'entityPrefix' => null, 95 | 'sortFields' => null, 96 | 'excludeFilters' => [], 97 | ]; 98 | 99 | $mergedConfig = array_merge($default, $config); 100 | 101 | foreach ($mergedConfig as $field => $value) { 102 | if (! property_exists($this, $field)) { 103 | throw new InvalidArgumentException('Invalid configuration setting: ' . $field); 104 | } 105 | } 106 | 107 | // Assigning properties explicitly is phpstan friendly 108 | $this->group = $mergedConfig['group']; 109 | $this->groupSuffix = $mergedConfig['groupSuffix']; 110 | $this->useHydratorCache = $mergedConfig['useHydratorCache']; 111 | $this->limit = $mergedConfig['limit']; 112 | $this->globalEnable = $mergedConfig['globalEnable']; 113 | $this->ignoreFields = $mergedConfig['ignoreFields']; 114 | $this->globalByValue = $mergedConfig['globalByValue']; 115 | $this->entityPrefix = $mergedConfig['entityPrefix']; 116 | $this->sortFields = $mergedConfig['sortFields']; 117 | $this->excludeFilters = $mergedConfig['excludeFilters']; 118 | } 119 | 120 | public function getGroup(): string 121 | { 122 | return $this->group; 123 | } 124 | 125 | public function getGroupSuffix(): string|null 126 | { 127 | return $this->groupSuffix; 128 | } 129 | 130 | public function getUseHydratorCache(): bool 131 | { 132 | return $this->useHydratorCache; 133 | } 134 | 135 | public function getLimit(): int 136 | { 137 | return $this->limit; 138 | } 139 | 140 | public function getGlobalEnable(): bool 141 | { 142 | return $this->globalEnable; 143 | } 144 | 145 | /** @return string[] */ 146 | public function getIgnoreFields(): array 147 | { 148 | return $this->ignoreFields; 149 | } 150 | 151 | public function getGlobalByValue(): bool|null 152 | { 153 | return $this->globalByValue; 154 | } 155 | 156 | public function getEntityPrefix(): string|null 157 | { 158 | return $this->entityPrefix; 159 | } 160 | 161 | public function getSortFields(): bool|null 162 | { 163 | return $this->sortFields; 164 | } 165 | 166 | /** @return Filters[] */ 167 | public function getExcludeFilters(): array 168 | { 169 | return $this->excludeFilters; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Container.php: -------------------------------------------------------------------------------- 1 | register[strtolower($id)]); 27 | } 28 | 29 | /** @throws Error */ 30 | public function get(string $id): mixed 31 | { 32 | $id = strtolower($id); 33 | 34 | if (! $this->has($id)) { 35 | throw new Error($id . ' is not registered'); 36 | } 37 | 38 | if ($this->register[$id] instanceof Closure) { 39 | $closure = $this->register[$id]; 40 | 41 | $this->register[$id] = $closure($this); 42 | } 43 | 44 | return $this->register[$id]; 45 | } 46 | 47 | /** 48 | * This allows for a duplicate id to overwrite an existing registration 49 | */ 50 | public function set(string $id, mixed $value): self 51 | { 52 | $id = strtolower($id); 53 | 54 | $this->register[$id] = $value; 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * This function allows for buildable types. The Type\Connection type is created this way 61 | * because it relies on the entity object type. To create a custom buildable object type 62 | * it must implement the Buildable interface. 63 | * 64 | * @param class-string $className 65 | * 66 | * @throws Error 67 | * @throws ReflectionException 68 | */ 69 | public function build(string $className, string $typeName, mixed ...$params): mixed 70 | { 71 | if ($this->has($typeName)) { 72 | return $this->get($typeName); 73 | } 74 | 75 | $reflectionClass = new ReflectionClass($className); 76 | assert($reflectionClass->implementsInterface(Buildable::class)); 77 | 78 | return $this 79 | ->set($typeName, new $className($this, $typeName, $params)) 80 | ->get($typeName); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Driver.php: -------------------------------------------------------------------------------- 1 | type($id, $eventName); 25 | 26 | return $this->get(Type\TypeContainer::class) 27 | ->build(Type\Connection::class, $objectType->name, $objectType); 28 | } 29 | 30 | /** 31 | * A shortcut into the EntityTypeContainer and TypeContainer 32 | * 33 | * @throws Error 34 | */ 35 | public function type(string $id, string|null $eventName = null): mixed 36 | { 37 | $entityTypeContainer = $this->get(Type\Entity\EntityTypeContainer::class); 38 | if ($entityTypeContainer->has($id)) { 39 | return $entityTypeContainer->get($id, $eventName)->getObjectType(); 40 | } 41 | 42 | $typeContainer = $this->get(Type\TypeContainer::class); 43 | if ($typeContainer->has($id)) { 44 | return $typeContainer->get($id); 45 | } 46 | 47 | throw new Error('Type "' . $id . '" is not registered'); 48 | } 49 | 50 | /** 51 | * Return an InputObject type of filters for a connection 52 | * Requires the internal representation of the entity 53 | * 54 | * @throws Error 55 | */ 56 | public function filter(string $id): object 57 | { 58 | return $this->get(Filter\FilterFactory::class)->get( 59 | $this->get(Type\Entity\EntityTypeContainer::class)->get($id), 60 | ); 61 | } 62 | 63 | /** 64 | * Pagination for a connection 65 | * 66 | * @throws Error 67 | */ 68 | public function pagination(): object 69 | { 70 | return $this->type('pagination'); 71 | } 72 | 73 | /** 74 | * Resolve a connection 75 | * 76 | * @throws Error 77 | */ 78 | public function resolve(string $id, string|null $eventName = null): Closure 79 | { 80 | return $this->get(Resolve\ResolveEntityFactory::class)->get( 81 | $this->get(Type\Entity\EntityTypeContainer::class)->get($id), 82 | $eventName, 83 | ); 84 | } 85 | 86 | /** 87 | * @param string[] $requiredFields An optional list of just the required fields you want for the mutation. 88 | * @param string[] $optionalFields An optional list of optional fields you want for the mutation. 89 | */ 90 | public function input(string $entityClass, array $requiredFields = [], array $optionalFields = []): InputObjectType 91 | { 92 | return $this->get(Input\InputFactory::class)->get($entityClass, $requiredFields, $optionalFields); 93 | } 94 | 95 | /** 96 | * Return an array defining a GraphQL endpoint. 97 | * 98 | * @return mixed[] 99 | */ 100 | public function completeConnection( 101 | string $id, 102 | string|null $entityDefinitionEventName = null, 103 | string|null $resolveEventName = null, 104 | ): array { 105 | return [ 106 | 'type' => $this->connection($id, $entityDefinitionEventName), 107 | 'args' => [ 108 | 'filter' => $this->filter($id), 109 | 'pagination' => $this->pagination(), 110 | ], 111 | 'resolve' => $this->resolve($id, $resolveEventName), 112 | ]; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Event/Criteria.php: -------------------------------------------------------------------------------- 1 | $collection 22 | * @param mixed[] $args 23 | */ 24 | public function __construct( 25 | protected readonly string $eventName, 26 | protected readonly DoctrineCriteria $criteria, 27 | protected Collection $collection, 28 | protected readonly int $offset, 29 | protected readonly int $limit, 30 | protected readonly mixed $objectValue, 31 | protected readonly array $args, 32 | protected readonly mixed $context, 33 | protected readonly ResolveInfo $info, 34 | ) { 35 | } 36 | 37 | public function eventName(): string 38 | { 39 | return $this->eventName; 40 | } 41 | 42 | public function getCriteria(): DoctrineCriteria 43 | { 44 | return $this->criteria; 45 | } 46 | 47 | /** @return PersistentCollection */ 48 | public function getCollection(): Collection 49 | { 50 | return $this->collection; 51 | } 52 | 53 | /** @param Collection $collection */ 54 | public function setCollection(Collection $collection): void 55 | { 56 | $this->collection = $collection; 57 | } 58 | 59 | public function getOffset(): int 60 | { 61 | return $this->offset; 62 | } 63 | 64 | public function getLimit(): int 65 | { 66 | return $this->limit; 67 | } 68 | 69 | public function getObjectValue(): mixed 70 | { 71 | return $this->objectValue; 72 | } 73 | 74 | /** @return mixed[] */ 75 | public function getArgs(): array 76 | { 77 | return $this->args; 78 | } 79 | 80 | public function getContext(): mixed 81 | { 82 | return $this->context; 83 | } 84 | 85 | public function getInfo(): ResolveInfo 86 | { 87 | return $this->info; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Event/EntityDefinition.php: -------------------------------------------------------------------------------- 1 | */ 17 | public function __construct( 18 | protected readonly ArrayObject $definition, 19 | protected readonly string $eventName, 20 | ) { 21 | } 22 | 23 | public function eventName(): string 24 | { 25 | return $this->eventName; 26 | } 27 | 28 | public function getDefinition(): ArrayObject 29 | { 30 | return $this->definition; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Event/Metadata.php: -------------------------------------------------------------------------------- 1 | eventName; 25 | } 26 | 27 | public function getMetadata(): ArrayObject 28 | { 29 | return $this->metadata; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Event/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | eventName; 33 | } 34 | 35 | public function getQueryBuilder(): DoctrineQueryBuilder 36 | { 37 | return $this->queryBuilder; 38 | } 39 | 40 | public function getOffset(): int 41 | { 42 | return $this->offset; 43 | } 44 | 45 | public function getLimit(): int 46 | { 47 | return $this->limit; 48 | } 49 | 50 | public function getObjectValue(): mixed 51 | { 52 | return $this->objectValue; 53 | } 54 | 55 | /** @return mixed[] */ 56 | public function getArgs(): array 57 | { 58 | return $this->args; 59 | } 60 | 61 | public function getContext(): mixed 62 | { 63 | return $this->context; 64 | } 65 | 66 | public function getInfo(): ResolveInfo 67 | { 68 | return $this->info; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Filter/FilterFactory.php: -------------------------------------------------------------------------------- 1 | getTypeName() . '_' . ucwords((string) $associationName) 59 | : 'Filter_' . $targetEntity->getTypeName(); 60 | 61 | if ($this->typeContainer->has($typeName)) { 62 | return $this->typeContainer->get($typeName); 63 | } 64 | 65 | $entityMetadata = $targetEntity->getMetadata(); 66 | 67 | $excludedFilters = array_unique( 68 | array_merge( 69 | Filters::fromArray($entityMetadata['excludeFilters'] ?? []), 70 | Filters::fromArray($this->config->getExcludeFilters()), 71 | ), 72 | SORT_REGULAR, 73 | ); 74 | 75 | // Get the allowed filters 76 | $allowedFilters = array_udiff(Filters::cases(), $excludedFilters, static function ($a, $b) { 77 | return $a->value <=> $b->value; 78 | }); 79 | 80 | // Limit association filters 81 | if ($associationName) { 82 | $excludeFilters = Filters::fromArray($associationMetadata['excludeFilters'] ?? []); 83 | $allowedFilters = array_filter($allowedFilters, static function ($value) use ($excludeFilters) { 84 | return ! in_array($value, $excludeFilters); 85 | }); 86 | } 87 | 88 | $fields = $this->addFields($targetEntity, $allowedFilters); 89 | $fields = array_merge($fields, $this->addAssociations($targetEntity, $allowedFilters)); 90 | 91 | $inputObject = new GraphQLInputObjectType([ 92 | 'name' => $typeName, 93 | 'fields' => static fn () => $fields, 94 | ]); 95 | 96 | $this->typeContainer->set($typeName, $inputObject); 97 | 98 | return $inputObject; 99 | } 100 | 101 | /** 102 | * Add each field filters 103 | * 104 | * @param Filters[] $allowedFilters 105 | * 106 | * @return array 107 | */ 108 | protected function addFields(Entity $targetEntity, array $allowedFilters): array 109 | { 110 | $fields = []; 111 | 112 | $classMetadata = $this->entityManager->getClassMetadata($targetEntity->getEntityClass()); 113 | $entityMetadata = $targetEntity->getMetadata(); 114 | 115 | foreach ($classMetadata->getFieldNames() as $fieldName) { 116 | // Only process fields that are in the graphql metadata 117 | if (! in_array($fieldName, array_keys($entityMetadata['fields']))) { 118 | continue; 119 | } 120 | 121 | $type = $this->typeContainer 122 | ->get($entityMetadata['fields'][$fieldName]['type']); 123 | 124 | // Custom types may hit this condition 125 | if (! $type instanceof ScalarType) { 126 | continue; 127 | } 128 | 129 | // Skip Blob fields 130 | if ($type->name() === 'Blob') { 131 | continue; 132 | } 133 | 134 | // Limit field filters 135 | if ( 136 | isset($entityMetadata['fields'][$fieldName]['excludeFilters']) 137 | && count($entityMetadata['fields'][$fieldName]['excludeFilters']) 138 | ) { 139 | $fieldExcludeFilters = Filters::fromArray($entityMetadata['fields'][$fieldName]['excludeFilters']); 140 | $allowedFilters = array_filter( 141 | $allowedFilters, 142 | static function ($value) use ($fieldExcludeFilters) { 143 | return ! in_array($value, $fieldExcludeFilters); 144 | }, 145 | ); 146 | } 147 | 148 | // Remove filters that are not allowed for this field type 149 | $filteredFilters = $this->filterFiltersByType($allowedFilters, $type); 150 | 151 | // ScalarType field filters are named by their field type 152 | // and a hash of the allowed filters 153 | $filterTypeName = 'Filters_' . $type->name() . '_' . md5(serialize($filteredFilters)); 154 | 155 | if ($this->typeContainer->has($filterTypeName)) { 156 | $fieldType = $this->typeContainer->get($filterTypeName); 157 | } else { 158 | $fieldType = new Field($this->typeContainer, $type, $filteredFilters); 159 | $this->typeContainer->set($filterTypeName, $fieldType); 160 | } 161 | 162 | $alias = $targetEntity->getExtractionMap()[$fieldName] ?? null; 163 | 164 | $fields[$alias ?? $fieldName] = [ 165 | 'name' => $alias ?? $fieldName, 166 | 'type' => $fieldType, 167 | 'description' => $type->name() . ' Filters', 168 | ]; 169 | } 170 | 171 | return $fields; 172 | } 173 | 174 | /** 175 | * Some relationships have an `eq` filter for the id 176 | * 177 | * @param Filters[] $allowedFilters 178 | * 179 | * @return array 180 | */ 181 | protected function addAssociations(Entity $targetEntity, array $allowedFilters): array 182 | { 183 | $fields = []; 184 | 185 | $classMetadata = $this->entityManager->getClassMetadata($targetEntity->getEntityClass()); 186 | $entityMetadata = $targetEntity->getMetadata(); 187 | 188 | // Add eq filter for to-one associations 189 | foreach ($classMetadata->getAssociationNames() as $associationName) { 190 | // Only process fields which are in the graphql metadata 191 | if (! isset($entityMetadata['fields'][$associationName])) { 192 | continue; 193 | } 194 | 195 | $associationMetadata = $classMetadata->getAssociationMapping($associationName); 196 | 197 | if ( 198 | in_array($associationMetadata['type'], [ 199 | ClassMetadata::TO_MANY, 200 | ClassMetadata::MANY_TO_MANY, 201 | ClassMetadata::ONE_TO_MANY, 202 | ]) 203 | || ! in_array(Filters::EQ, $allowedFilters) 204 | ) { 205 | continue; 206 | } 207 | 208 | $filterTypeName = 'Filters_ID_' . md5(serialize($allowedFilters)); 209 | 210 | if (! $this->typeContainer->has($filterTypeName)) { 211 | $this->typeContainer->set($filterTypeName, new Association($this->typeContainer, Type::id(), [Filters::EQ])); 212 | } 213 | 214 | // eq filter is for association id from parent entity 215 | $fields[$associationName] = [ 216 | 'name' => $associationName, 217 | 'type' => $this->typeContainer->get($filterTypeName), 218 | 'description' => 'Association Filters', 219 | ]; 220 | } 221 | 222 | return $fields; 223 | } 224 | 225 | /** 226 | * Filter the allowed filters based on the field type 227 | * 228 | * @param Filters[] $filters 229 | * 230 | * @return Filters[] 231 | */ 232 | protected function filterFiltersByType(array $filters, ScalarType $type): array 233 | { 234 | $filterCollection = new ArrayCollection($filters); 235 | 236 | // Numbers 237 | if ( 238 | in_array($type->name(), [ 239 | 'Float', 240 | 'ID', 241 | 'Int', 242 | 'Integer', 243 | ]) 244 | ) { 245 | $filterCollection->removeElement(Filters::CONTAINS); 246 | $filterCollection->removeElement(Filters::STARTSWITH); 247 | $filterCollection->removeElement(Filters::ENDSWITH); 248 | } elseif ($type->name() === 'Boolean') { 249 | $filterCollection->removeElement(Filters::LT); 250 | $filterCollection->removeElement(Filters::LTE); 251 | $filterCollection->removeElement(Filters::GT); 252 | $filterCollection->removeElement(Filters::GTE); 253 | $filterCollection->removeElement(Filters::BETWEEN); 254 | $filterCollection->removeElement(Filters::CONTAINS); 255 | $filterCollection->removeElement(Filters::STARTSWITH); 256 | $filterCollection->removeElement(Filters::ENDSWITH); 257 | } elseif ( 258 | in_array($type->name(), [ 259 | 'String', 260 | 'Text', 261 | ]) 262 | ) { 263 | $filterCollection->removeElement(Filters::LT); 264 | $filterCollection->removeElement(Filters::LTE); 265 | $filterCollection->removeElement(Filters::GT); 266 | $filterCollection->removeElement(Filters::GTE); 267 | $filterCollection->removeElement(Filters::BETWEEN); 268 | } elseif ( 269 | in_array($type->name(), [ 270 | 'Date', 271 | 'DateTime', 272 | 'DateTimeImmutable', 273 | 'DateTimeTZ', 274 | 'DateTimeTZImmutable', 275 | 'Time', 276 | 'TimeImmutable', 277 | ]) 278 | ) { 279 | $filterCollection->removeElement(Filters::CONTAINS); 280 | $filterCollection->removeElement(Filters::STARTSWITH); 281 | $filterCollection->removeElement(Filters::ENDSWITH); 282 | } 283 | 284 | return $filterCollection->toArray(); 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/Filter/Filters.php: -------------------------------------------------------------------------------- 1 | 'Equals', 42 | self::NEQ => 'Not equals', 43 | self::LT => 'Less than', 44 | self::LTE => 'Less than or equals', 45 | self::GT => 'Greater than', 46 | self::GTE => 'Greater than or equals', 47 | self::BETWEEN => 'Is between from and to inclusive of from and to', 48 | self::CONTAINS => 'Contains the value. Strings only.', 49 | self::STARTSWITH => 'Starts with the value. Strings only.', 50 | self::ENDSWITH => 'Ends with the value. Strings only.', 51 | self::IN => 'In the array of values', 52 | self::NOTIN => 'Not in the array of values', 53 | self::ISNULL => 'Is null', 54 | self::SORT => 'Sort by field. ASC or DESC.', 55 | }; 56 | } 57 | 58 | /** 59 | * Fetch the GraphQL type for the filter 60 | */ 61 | public function type(ScalarType|ListOfType $type): Type 62 | { 63 | return match ($this) { 64 | self::EQ => $type, 65 | self::NEQ => $type, 66 | self::LT => $type, 67 | self::LTE => $type, 68 | self::GT => $type, 69 | self::GTE => $type, 70 | self::BETWEEN => new Between($type), 71 | self::CONTAINS => $type, 72 | self::STARTSWITH => $type, 73 | self::ENDSWITH => $type, 74 | self::IN => Type::listOf($type), 75 | self::NOTIN => Type::listOf($type), 76 | self::ISNULL => Type::boolean(), 77 | self::SORT => Type::string(), 78 | }; 79 | } 80 | 81 | /** 82 | * Convert an array of Filters or strings to an array of Filters 83 | * 84 | * @param array|Filters[] $filters 85 | * 86 | * @return Filters[] 87 | */ 88 | public static function fromArray(array $filters): array 89 | { 90 | $filters = array_map( 91 | static function ($filter) { 92 | return is_string($filter) ? Filters::from($filter) : $filter; 93 | }, 94 | $filters, 95 | ); 96 | 97 | return $filters; 98 | } 99 | 100 | /** 101 | * Covert an array of enum values to an array of strings 102 | * 103 | * @param Filters[] $filters 104 | * 105 | * @return string[] 106 | */ 107 | public static function toStringArray(array $filters): array 108 | { 109 | return array_map( 110 | static function (Filters $filter) { 111 | return $filter->value; 112 | }, 113 | $filters, 114 | ); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Filter/InputObjectType/Association.php: -------------------------------------------------------------------------------- 1 | name() : uniqid(); 22 | 23 | parent::__construct([ 24 | 'name' => 'Between_' . $name, 25 | 'description' => 'Between `from` and `to`', 26 | 'fields' => [ 27 | 'from' => new InputObjectField([ 28 | 'name' => 'from', 29 | 'type' => $type, 30 | 'description' => 'Low value of between', 31 | ]), 32 | 'to' => new InputObjectField([ 33 | 'name' => 'to', 34 | 'type' => $type, 35 | 'description' => 'High value of between', 36 | ]), 37 | ], 38 | ]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Filter/InputObjectType/Field.php: -------------------------------------------------------------------------------- 1 | $fields */ 30 | $fields = []; 31 | 32 | foreach ($allowedFilters as $filter) { 33 | $fields[$filter->value] = [ 34 | 'name' => $filter->value, 35 | 'type' => $filter->type($type), 36 | 'description' => $filter->description(), 37 | ]; 38 | 39 | // Custom types may hit this condition 40 | // @codeCoverageIgnoreStart 41 | if (! $type instanceof ScalarType) { 42 | continue; 43 | } 44 | 45 | // @codeCoverageIgnoreEnd 46 | 47 | // Between is a special case filter. 48 | // To avoid creating a new Between type for each field, 49 | // check if the Between type exists and reuse it. 50 | if (! $fields[$filter->value]['type'] instanceof Between) { 51 | continue; 52 | } 53 | 54 | if (! $typeContainer->has('Between_' . $type->name())) { 55 | $typeContainer->set('Between_' . $type->name(), new Between($type)); 56 | } 57 | 58 | $fields[$filter->value]['type'] = $typeContainer->get('Between_' . $type->name()); 59 | } 60 | 61 | $typeName = $type instanceof ScalarType ? $type->name() : uniqid(); 62 | 63 | // ScalarType field filters are named by their field type 64 | // and a hash of the allowed filters 65 | parent::__construct([ 66 | 'name' => 'Filters_' . $typeName . '_' . md5(serialize($allowedFilters)), 67 | 'description' => 'Field filters', 68 | 'fields' => static fn () => $fields, 69 | ]); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Filter/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | > $filterTypes 24 | */ 25 | public function apply( 26 | array $filterTypes, 27 | DoctrineQueryBuilder $queryBuilder, 28 | Entity $entity, 29 | ): void { 30 | foreach ($filterTypes as $field => $filters) { 31 | // Resolve aliases 32 | $field = array_flip($entity->getExtractionMap())[$field] ?? $field; 33 | $queryBuilderField = 'entity.' . $field; 34 | 35 | foreach ($filters as $filter => $value) { 36 | $filter = Filters::from($filter); 37 | 38 | if (method_exists($this, $filter->value) === false) { 39 | $this->default($filter, $queryBuilderField, $value, $queryBuilder); 40 | } else { 41 | $this->{$filter->value}($queryBuilderField, $value, $queryBuilder); 42 | } 43 | } 44 | } 45 | } 46 | 47 | /** 48 | * For filters that do not have a special method, use this method 49 | */ 50 | protected function default(Filters $filter, string $field, mixed $value, DoctrineQueryBuilder $queryBuilder): void 51 | { 52 | $parameter = 'p' . uniqid(); 53 | $queryBuilder 54 | ->andWhere( 55 | $queryBuilder->expr()->{$filter->value}($field, ':' . $parameter), 56 | ) 57 | ->setParameter($parameter, $value); 58 | } 59 | 60 | /** @param array $value */ 61 | protected function between(string $field, array $value, DoctrineQueryBuilder $queryBuilder): void 62 | { 63 | $from = 'p' . uniqid(); 64 | $to = 'p' . uniqid(); 65 | $queryBuilder 66 | ->andWhere( 67 | $queryBuilder->expr()->between( 68 | $field, 69 | ':' . $from, 70 | ':' . $to, 71 | ), 72 | ) 73 | ->setParameter($from, $value['from']) 74 | ->setParameter($to, $value['to']); 75 | } 76 | 77 | protected function contains(string $field, string $value, DoctrineQueryBuilder $queryBuilder): void 78 | { 79 | $parameter = 'p' . uniqid(); 80 | $queryBuilder 81 | ->andWhere( 82 | $queryBuilder->expr()->like($field, ':' . $parameter), 83 | ) 84 | ->setParameter($parameter, '%' . $value . '%'); 85 | } 86 | 87 | public function startsWith(string $field, string $value, DoctrineQueryBuilder $queryBuilder): void 88 | { 89 | $parameter = 'p' . uniqid(); 90 | $queryBuilder 91 | ->andWhere( 92 | $queryBuilder->expr()->like($field, ':' . $parameter), 93 | ) 94 | ->setParameter($parameter, $value . '%'); 95 | } 96 | 97 | public function endsWith(string $field, string $value, DoctrineQueryBuilder $queryBuilder): void 98 | { 99 | $parameter = 'p' . uniqid(); 100 | $queryBuilder 101 | ->andWhere( 102 | $queryBuilder->expr()->like($field, ':' . $parameter), 103 | ) 104 | ->setParameter($parameter, '%' . $value); 105 | } 106 | 107 | public function isnull(string $field, bool $value, DoctrineQueryBuilder $queryBuilder): void 108 | { 109 | if ($value === true) { 110 | $queryBuilder->andWhere( 111 | $queryBuilder->expr()->isNull($field), 112 | ); 113 | } else { 114 | $queryBuilder->andWhere( 115 | $queryBuilder->expr()->isNotNull($field), 116 | ); 117 | } 118 | } 119 | 120 | protected function sort(string $field, string $direction, DoctrineQueryBuilder $queryBuilder): void 121 | { 122 | $queryBuilder->addOrderBy($field, $direction); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Hydrator/HydratorContainer.php: -------------------------------------------------------------------------------- 1 | set(Strategy\AssociationDefault::class, static fn () => new Strategy\AssociationDefault()) 32 | ->set(Strategy\FieldDefault::class, static fn () => new Strategy\FieldDefault()) 33 | ->set(Strategy\ToBoolean::class, static fn () => new Strategy\ToBoolean()) 34 | ->set(Strategy\ToFloat::class, static fn () => new Strategy\ToFloat()) 35 | ->set(Strategy\ToInteger::class, static fn () => new Strategy\ToInteger()); 36 | } 37 | 38 | /** @throws Error */ 39 | public function get(string $id): mixed 40 | { 41 | if ($this->has($id)) { 42 | return parent::get($id); 43 | } 44 | 45 | $entity = $this->entityTypeContainer->get($id); 46 | $metadata = $entity->getMetadata(); 47 | $hydrator = new DoctrineObject($this->entityManager, $metadata['byValue']); 48 | 49 | // Create field strategy and assign to hydrator 50 | foreach ($metadata['fields'] as $fieldName => $fieldMetadata) { 51 | assert( 52 | in_array(StrategyInterface::class, class_implements($fieldMetadata['hydratorStrategy'])), 53 | 'Strategy must implement ' . StrategyInterface::class, 54 | ); 55 | 56 | $hydrator->addStrategy($fieldName, $this->get($fieldMetadata['hydratorStrategy'])); 57 | } 58 | 59 | // Create naming strategy for aliases and assign to hydrator 60 | if ($entity->getExtractionMap()) { 61 | $hydrator->setNamingStrategy( 62 | MapNamingStrategy::createFromExtractionMap($entity->getExtractionMap()), 63 | ); 64 | } 65 | 66 | $this->set($id, $hydrator); 67 | 68 | return $hydrator; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Hydrator/Strategy/AssociationDefault.php: -------------------------------------------------------------------------------- 1 | inflector = $inflector ?? InflectorFactory::create()->build(); 40 | } 41 | 42 | public function setCollectionName(string $collectionName): void 43 | { 44 | $this->collectionName = $collectionName; 45 | } 46 | 47 | public function getCollectionName(): string 48 | { 49 | if ($this->collectionName === null) { 50 | throw new LogicException('Collection name has not been set.'); 51 | } 52 | 53 | return $this->collectionName; 54 | } 55 | 56 | public function setClassMetadata(ClassMetadata $classMetadata): void 57 | { 58 | $this->metadata = $classMetadata; 59 | } 60 | 61 | public function getClassMetadata(): ClassMetadata 62 | { 63 | if ($this->metadata === null) { 64 | throw new LogicException('Class metadata has not been set.'); 65 | } 66 | 67 | return $this->metadata; 68 | } 69 | 70 | public function setObject(object $object): void 71 | { 72 | $this->object = $object; 73 | } 74 | 75 | public function getObject(): object 76 | { 77 | if ($this->object === null) { 78 | throw new LogicException('Object has not been set.'); 79 | } 80 | 81 | return $this->object; 82 | } 83 | 84 | /** 85 | * Converts the given value so that it can be extracted by the hydrator. 86 | * 87 | * @param mixed $value The original value. 88 | * @param object|null $object (optional) The original object for context. 89 | * 90 | * @return mixed Returns the value that should be extracted. 91 | */ 92 | public function extract(mixed $value, object|null $object = null): mixed 93 | { 94 | return $value; 95 | } 96 | 97 | protected function getInflector(): Inflector 98 | { 99 | return $this->inflector; 100 | } 101 | 102 | /** 103 | * Return the collection by value (using the public API) 104 | * 105 | * @return DoctrineCollection 106 | * 107 | * @throws InvalidArgumentException 108 | */ 109 | protected function getCollectionFromObjectByValue(): DoctrineCollection 110 | { 111 | $object = $this->getObject(); 112 | $getter = 'get' . $this->getInflector()->classify($this->getCollectionName()); 113 | 114 | if (! method_exists($object, $getter)) { 115 | throw new InvalidArgumentException( 116 | sprintf( 117 | 'The getter %s to access collection %s in object %s does not exist', 118 | $getter, 119 | $this->getCollectionName(), 120 | $object::class, 121 | ), 122 | ); 123 | } 124 | 125 | $collection = $object->$getter(); 126 | 127 | if (is_array($collection)) { 128 | $collection = new ArrayCollection($collection); 129 | } 130 | 131 | return $collection; 132 | } 133 | 134 | /** 135 | * Return the collection by reference (not using the public API) 136 | * 137 | * @return DoctrineCollection 138 | * 139 | * @throws InvalidArgumentException|ReflectionException 140 | */ 141 | protected function getCollectionFromObjectByReference(): DoctrineCollection 142 | { 143 | $object = $this->getObject(); 144 | $refl = $this->getClassMetadata()->getReflectionClass(); 145 | $reflProperty = $refl->getProperty($this->getCollectionName()); 146 | 147 | $reflProperty->setAccessible(true); 148 | 149 | return $reflProperty->getValue($object); 150 | } 151 | 152 | /** 153 | * This method is used internally by array_udiff to check if two objects are equal, according to their 154 | * SPL hash. This is needed because the native array_diff only compare strings 155 | */ 156 | protected function compareObjects(object $a, object $b): int 157 | { 158 | return spl_object_hash($a) <=> spl_object_hash($b); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/Hydrator/Strategy/FieldDefault.php: -------------------------------------------------------------------------------- 1 | entityTypeContainer->get($id); 46 | 47 | if (! count($requiredFields) && ! count($optionalFields)) { 48 | $this->addAllFieldsAsRequired($targetEntity, $fields); 49 | } else { 50 | $this->addRequiredFields($targetEntity, $requiredFields, $fields); 51 | $this->addOptionalFields($targetEntity, $optionalFields, $fields); 52 | } 53 | 54 | return new InputObjectType([ 55 | 'name' => $targetEntity->getTypeName() . '_Input_' . uniqid(), 56 | 'description' => $targetEntity->getDescription(), 57 | 'fields' => static fn () => $fields, 58 | ]); 59 | } 60 | 61 | /** 62 | * @param string[] $optionalFields 63 | * @param array $fields 64 | */ 65 | protected function addOptionalFields( 66 | mixed $targetEntity, 67 | array $optionalFields, 68 | array &$fields, 69 | ): void { 70 | foreach ($this->entityManager->getClassMetadata($targetEntity->getEntityClass())->getFieldNames() as $fieldName) { 71 | if (! in_array($fieldName, $optionalFields)) { 72 | continue; 73 | } 74 | 75 | /** 76 | * Do not include identifiers as input. In the majority of cases there will be 77 | * no reason to set or update an identifier. For the case where an identifier 78 | * should be set or updated, this factory is not the correct solution. 79 | * 80 | * @phpcs-disable 81 | */ 82 | if ($this->entityManager->getClassMetadata($targetEntity->getEntityClass())->isIdentifier($fieldName)) { 83 | throw new Exception('Identifier ' . $fieldName . ' is an invalid input.'); 84 | } 85 | 86 | $alias = $targetEntity->getExtractionMap()[$fieldName] ?? null; 87 | 88 | $fields[$alias ?? $fieldName] = new InputObjectField([ 89 | 'name' => $alias ?? $fieldName, 90 | 'description' => (string) $targetEntity->getMetadata()['fields'][$fieldName]['description'], 91 | 'type' => $this->typeContainer->get($targetEntity->getMetadata()['fields'][$fieldName]['type']), 92 | ]); 93 | } 94 | } 95 | 96 | /** 97 | * @param string[] $requiredFields 98 | * @param array $fields 99 | */ 100 | protected function addRequiredFields( 101 | mixed $targetEntity, 102 | array $requiredFields, 103 | array &$fields, 104 | ): void { 105 | foreach ($this->entityManager->getClassMetadata($targetEntity->getEntityClass())->getFieldNames() as $fieldName) { 106 | if (! in_array($fieldName, $requiredFields)) { 107 | continue; 108 | } 109 | 110 | /** 111 | * Do not include identifiers as input. In the majority of cases there will be 112 | * no reason to set or update an identifier. For the case where an identifier 113 | * should be set or updated, this factory is not the correct solution. 114 | */ 115 | if ($this->entityManager->getClassMetadata($targetEntity->getEntityClass())->isIdentifier($fieldName)) { 116 | throw new Exception('Identifier ' . $fieldName . ' is an invalid input.'); 117 | } 118 | 119 | $alias = $targetEntity->getExtractionMap()[$fieldName] ?? null; 120 | 121 | $fields[$alias ?? $fieldName] = new InputObjectField([ 122 | 'name' => $alias ?? $fieldName, 123 | 'description' => (string) $targetEntity->getMetadata()['fields'][$fieldName]['description'], 124 | 'type' => Type::nonNull($this->typeContainer->get( 125 | $targetEntity->getMetadata()['fields'][$fieldName]['type'], 126 | )), 127 | ]); 128 | } 129 | } 130 | 131 | /** @param array $fields */ 132 | protected function addAllFieldsAsRequired(mixed $targetEntity, array &$fields): void 133 | { 134 | foreach ($this->entityManager->getClassMetadata($targetEntity->getEntityClass())->getFieldNames() as $fieldName) { 135 | /** 136 | * Do not include identifiers as input. In the majority of cases there will be 137 | * no reason to set or update an identifier. For the case where an identifier 138 | * should be set or updated, this factory is not the correct solution. 139 | */ 140 | if ($this->entityManager->getClassMetadata($targetEntity->getEntityClass())->isIdentifier($fieldName)) { 141 | continue; 142 | } 143 | 144 | $alias = $targetEntity->getExtractionMap()[$fieldName] ?? null; 145 | 146 | $fields[$alias ?? $fieldName] = new InputObjectField([ 147 | 'name' => $alias ?? $fieldName, 148 | 'description' => (string) $targetEntity->getMetadata()['fields'][$fieldName]['description'], 149 | 'type' => Type::nonNull($this->typeContainer->get($targetEntity->getMetadata()['fields'][$fieldName]['type'])), 150 | ]); 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Metadata/Common/MetadataFactory.php: -------------------------------------------------------------------------------- 1 | appendGroupSuffix($this->stripEntityPrefix($entityClass)); 48 | } 49 | 50 | /** 51 | * Strip the configured entityPrefix from the type name 52 | * 53 | * @param class-string $entityClass 54 | */ 55 | protected function stripEntityPrefix(string $entityClass): string 56 | { 57 | $entityClassWithPrefix = $entityClass; 58 | 59 | if ($this->getConfig()->getEntityPrefix() !== null) { 60 | if (strpos($entityClass, (string) $this->getConfig()->getEntityPrefix()) === 0) { 61 | $entityClassWithPrefix = substr($entityClass, strlen((string) $this->getConfig()->getEntityPrefix())); 62 | } 63 | } 64 | 65 | return str_replace('\\', '_', $entityClassWithPrefix); 66 | } 67 | 68 | /** 69 | * Append the configured groupSuffix to the type name 70 | */ 71 | protected function appendGroupSuffix(string $entityClass): string 72 | { 73 | if ($this->getConfig()->getGroupSuffix() !== null) { 74 | if ($this->getConfig()->getGroupSuffix()) { 75 | $entityClass .= '_' . $this->getConfig()->getGroupSuffix(); 76 | } 77 | } else { 78 | $entityClass .= '_' . $this->getConfig()->getGroup(); 79 | } 80 | 81 | return $entityClass; 82 | } 83 | 84 | /** 85 | * Because the Config class is not available in this class, 86 | * this method must be implemented in the child class 87 | */ 88 | abstract protected function getConfig(): Config; 89 | } 90 | -------------------------------------------------------------------------------- /src/Metadata/GlobalEnable.php: -------------------------------------------------------------------------------- 1 | metadata = new ArrayObject(); 30 | } 31 | 32 | /** @param class-string[] $entityClasses */ 33 | public function __invoke(array $entityClasses): ArrayObject 34 | { 35 | foreach ($entityClasses as $entityClass) { 36 | // Get extract by value or reference 37 | $byValue = $this->config->getGlobalByValue() ?? true; 38 | 39 | // Save entity-level metadata 40 | $this->metadata[$entityClass] = [ 41 | 'entityClass' => $entityClass, 42 | 'byValue' => $byValue, 43 | 'limit' => 0, 44 | 'fields' => [], 45 | 'excludeFilters' => [], 46 | 'description' => $entityClass, 47 | 'typeName' => $this->getTypeName($entityClass), 48 | ]; 49 | 50 | $this->buildFieldMetadata($entityClass); 51 | $this->buildAssociationMetadata($entityClass); 52 | } 53 | 54 | $this->eventDispatcher->dispatch( 55 | new Metadata($this->metadata, 'metadata.build'), 56 | ); 57 | 58 | return $this->metadata; 59 | } 60 | 61 | /** @param class-string $entityClass */ 62 | private function buildFieldMetadata(string $entityClass): void 63 | { 64 | $entityClassMetadata = $this->entityManager->getMetadataFactory()->getMetadataFor($entityClass); 65 | 66 | foreach ($entityClassMetadata->getFieldNames() as $fieldName) { 67 | if (in_array($fieldName, $this->config->getIgnoreFields())) { 68 | continue; 69 | } 70 | 71 | $this->metadata[$entityClass]['fields'][$fieldName] = [ 72 | 'description' => $fieldName, 73 | 'type' => $entityClassMetadata->getTypeOfField($fieldName), 74 | 'hydratorStrategy' => $this->getDefaultStrategy($entityClassMetadata->getTypeOfField($fieldName)), 75 | 'excludeFilters' => [], 76 | ]; 77 | } 78 | } 79 | 80 | /** @param class-string $entityClass */ 81 | private function buildAssociationMetadata(string $entityClass): void 82 | { 83 | $entityClassMetadata = $this->entityManager->getMetadataFactory()->getMetadataFor($entityClass); 84 | 85 | foreach ($entityClassMetadata->getAssociationNames() as $associationName) { 86 | if (in_array($associationName, $this->config->getIgnoreFields())) { 87 | continue; 88 | } 89 | 90 | $this->metadata[$entityClass]['fields'][$associationName] = [ 91 | 'limit' => null, 92 | 'excludeFilters' => [], 93 | 'description' => $associationName, 94 | 'criteriaEventName' => null, 95 | 'hydratorStrategy' => Strategy\AssociationDefault::class, 96 | ]; 97 | } 98 | } 99 | 100 | protected function getConfig(): Config 101 | { 102 | return $this->config; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Metadata/MetadataFactory.php: -------------------------------------------------------------------------------- 1 | metadata)) { 42 | return $this->metadata; 43 | } 44 | 45 | // Fetch all entity classes from the entity manager 46 | $entityClasses = []; 47 | foreach ($this->entityManager->getMetadataFactory()->getAllMetadata() as $metadata) { 48 | $entityClasses[] = $metadata->getName(); 49 | } 50 | 51 | // If global enable is set, use the GlobalEnable class to build metadata 52 | if ($this->config->getGlobalEnable()) { 53 | $this->metadata = ($this->globalEnable)($entityClasses); 54 | 55 | return $this->metadata; 56 | } 57 | 58 | // Build metadata for each entity class 59 | foreach ($entityClasses as $entityClass) { 60 | $reflectionClass = new ReflectionClass($entityClass); 61 | 62 | $entityClassMetadata = $this->entityManager 63 | ->getMetadataFactory() 64 | ->getMetadataFor($reflectionClass->getName()); 65 | 66 | $this->buildMetadataForEntity($reflectionClass); 67 | $this->buildMetadataForFields($entityClassMetadata, $reflectionClass); 68 | $this->buildMetadataForAssociations($reflectionClass); 69 | } 70 | 71 | // Fire the metadata.build event 72 | $this->eventDispatcher->dispatch( 73 | new Metadata($this->metadata, 'metadata.build'), 74 | ); 75 | 76 | return $this->metadata; 77 | } 78 | 79 | /** 80 | * Using the entity class attributes, generate the metadata. 81 | * The buildmetadata* functions exist to simplify the buildMetadata 82 | * function. 83 | */ 84 | private function buildMetadataForEntity(ReflectionClass $reflectionClass): void 85 | { 86 | $entityInstance = null; 87 | 88 | // Fetch attributes for the entity class filterd by Attribute\Entity 89 | foreach ($reflectionClass->getAttributes(Attribute\Entity::class) as $attribute) { 90 | $instance = $attribute->newInstance(); 91 | 92 | // Only process attributes for the Config group 93 | if ($instance->getGroup() !== $this->config->getGroup()) { 94 | continue; 95 | } 96 | 97 | // Only one matching instance per group is allowed 98 | assert( 99 | ! $entityInstance, 100 | 'Duplicate attribute found for entity ' 101 | . $reflectionClass->getName() . ', group ' . $instance->getGroup(), 102 | ); 103 | $entityInstance = $instance; 104 | 105 | // Save entity-level metadata 106 | $this->metadata[$reflectionClass->getName()] = [ 107 | 'entityClass' => $reflectionClass->getName(), 108 | 'byValue' => $this->config->getGlobalByValue() ?? $instance->getByValue(), 109 | 'limit' => $instance->getLimit(), 110 | 'fields' => [], 111 | 'excludeFilters' => Filters::toStringArray($instance->getExcludeFilters()), 112 | 'description' => $instance->getDescription(), 113 | 'typeName' => $instance->getTypeName() 114 | ? $this->appendGroupSuffix($instance->getTypeName()) : 115 | $this->getTypeName($reflectionClass->getName()), 116 | ]; 117 | } 118 | } 119 | 120 | /** 121 | * Build the metadata for each field in an entity based on the Attribute\Field 122 | */ 123 | private function buildMetadataForFields( 124 | ClassMetadata $entityClassMetadata, 125 | ReflectionClass $reflectionClass, 126 | ): void { 127 | foreach ($entityClassMetadata->getFieldNames() as $fieldName) { 128 | $fieldInstance = null; 129 | $reflectionField = $reflectionClass->getProperty($fieldName); 130 | 131 | foreach ($reflectionField->getAttributes(Attribute\Field::class) as $attribute) { 132 | $instance = $attribute->newInstance(); 133 | 134 | // Only process attributes for the same group 135 | if ($instance->getGroup() !== $this->config->getGroup()) { 136 | continue; 137 | } 138 | 139 | // Only one matching instance per group is allowed 140 | assert( 141 | ! $fieldInstance, 142 | 'Duplicate attribute found for field ' 143 | . $fieldName . ', group ' . $instance->getGroup(), 144 | ); 145 | $fieldInstance = $instance; 146 | 147 | $fieldMetadata = [ 148 | 'alias' => $instance->getAlias(), 149 | 'description' => $instance->getDescription(), 150 | 'type' => $instance->getType() ?? $entityClassMetadata->getTypeOfField($fieldName), 151 | 'hydratorStrategy' => $instance->getHydratorStrategy() ?? 152 | $this->getDefaultStrategy($entityClassMetadata->getTypeOfField($fieldName)), 153 | 'excludeFilters' => Filters::toStringArray($instance->getExcludeFilters()), 154 | ]; 155 | 156 | $this->metadata[$reflectionClass->getName()]['fields'][$fieldName] = $fieldMetadata; 157 | } 158 | } 159 | } 160 | 161 | /** 162 | * Build the metadata for each field in an entity based on the Attribute\Association 163 | */ 164 | private function buildMetadataForAssociations( 165 | ReflectionClass $reflectionClass, 166 | ): void { 167 | // Fetch attributes for associations 168 | $associationNames = $this->entityManager->getMetadataFactory() 169 | ->getMetadataFor($reflectionClass->getName()) 170 | ->getAssociationNames(); 171 | 172 | foreach ($associationNames as $associationName) { 173 | $associationInstance = null; 174 | $reflectionAssociation = $reflectionClass->getProperty($associationName); 175 | 176 | foreach ($reflectionAssociation->getAttributes(Attribute\Association::class) as $attribute) { 177 | $instance = $attribute->newInstance(); 178 | 179 | // Only process attributes for the same group 180 | if ($instance->getGroup() !== $this->config->getGroup()) { 181 | continue; 182 | } 183 | 184 | // Only one matching instance per group is allowed 185 | assert( 186 | ! $associationInstance, 187 | 'Duplicate attribute found for association ' 188 | . $associationName . ', group ' . $instance->getGroup(), 189 | ); 190 | 191 | $associationInstance = $instance; 192 | 193 | $associationMetadata = [ 194 | 'alias' => $instance->getAlias(), 195 | 'limit' => $instance->getLimit(), 196 | 'description' => $instance->getDescription(), 197 | 'excludeFilters' => Filters::toStringArray($instance->getExcludeFilters()), 198 | 'criteriaEventName' => $instance->getCriteriaEventName(), 199 | 'hydratorStrategy' => $instance->getHydratorStrategy() ?? 200 | Strategy\AssociationDefault::class, 201 | ]; 202 | 203 | $this->metadata[$reflectionClass->getName()]['fields'][$associationName] = $associationMetadata; 204 | } 205 | } 206 | } 207 | 208 | protected function getConfig(): Config 209 | { 210 | return $this->config; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/Resolve/FieldResolver.php: -------------------------------------------------------------------------------- 1 | __load(); 45 | } 46 | 47 | $defaultProxyClassNameResolver = new DefaultProxyClassNameResolver(); 48 | 49 | $entityClass = $defaultProxyClassNameResolver->getClass($source); 50 | $splObjectHash = spl_object_hash($source); 51 | 52 | /** 53 | * For disabled hydrator cache, store only the last hydrator result and reuse for consecutive calls 54 | * then drop the cache if it doesn't hit. 55 | */ 56 | if (! $this->config->getUseHydratorCache()) { 57 | if (isset($this->extractValues[$splObjectHash])) { 58 | return $this->extractValues[$splObjectHash][$info->fieldName] ?? null; 59 | } 60 | 61 | $this->extractValues = []; 62 | 63 | $this->extractValues[$splObjectHash] = $this->entityTypeContainer 64 | ->get($entityClass) 65 | ->getHydrator()->extract($source); 66 | 67 | return $this->extractValues[$splObjectHash][$info->fieldName] ?? null; 68 | } 69 | 70 | // Use full hydrator cache 71 | if (isset($this->extractValues[$splObjectHash][$info->fieldName])) { 72 | return $this->extractValues[$splObjectHash][$info->fieldName] ?? null; 73 | } 74 | 75 | $this->extractValues[$splObjectHash] = $this->entityTypeContainer 76 | ->get($entityClass) 77 | ->getHydrator()->extract($source); 78 | 79 | return $this->extractValues[$splObjectHash][$info->fieldName] ?? null; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Resolve/ResolveEntityFactory.php: -------------------------------------------------------------------------------- 1 | getEntityClass(); 39 | $queryBuilderFilter = new QueryBuilderFilter(); 40 | 41 | $queryBuilder = $this->entityManager->createQueryBuilder(); 42 | $queryBuilder->select('entity') 43 | ->from($entityClass, 'entity'); 44 | 45 | if (isset($args['filter'])) { 46 | $queryBuilderFilter->apply($args['filter'], $queryBuilder, $entity); 47 | } 48 | 49 | return $this->buildPagination( 50 | entity: $entity, 51 | queryBuilder: $queryBuilder, 52 | eventName: $eventName, 53 | objectValue: $objectValue, 54 | args: $args, 55 | context: $context, 56 | info: $info, 57 | ); 58 | }; 59 | } 60 | 61 | /** @return mixed[] */ 62 | public function buildPagination( 63 | Entity $entity, 64 | QueryBuilder $queryBuilder, 65 | string|null $eventName, 66 | mixed ...$resolve, 67 | ): array { 68 | $paginationFields = [ 69 | 'first' => 0, 70 | 'last' => 0, 71 | 'before' => 0, 72 | 'after' => 0, 73 | ]; 74 | 75 | if (isset($resolve['args']['pagination'])) { 76 | foreach ($resolve['args']['pagination'] as $field => $value) { 77 | $paginationFields[$field] = $value; 78 | 79 | if ($field === 'after') { 80 | $paginationFields[$field] = (int) base64_decode($value, true) + 1; 81 | } 82 | 83 | if ($field !== 'before') { 84 | continue; 85 | } 86 | 87 | $paginationFields[$field] = (int) base64_decode($value, true); 88 | } 89 | } 90 | 91 | $offsetAndLimit = $this->calculateOffsetAndLimit($entity, $paginationFields); 92 | 93 | /** 94 | * Fire the event dispatcher using the passed event name. 95 | * Include all resolve variables. 96 | */ 97 | if ($eventName) { 98 | $this->eventDispatcher->dispatch( 99 | new QueryBuilderEvent( 100 | $eventName, 101 | $queryBuilder, 102 | (int) $offsetAndLimit['offset'], 103 | (int) $offsetAndLimit['limit'], 104 | ...$resolve, 105 | ), 106 | ); 107 | } 108 | 109 | if ($offsetAndLimit['offset']) { 110 | $queryBuilder->setFirstResult($offsetAndLimit['offset']); 111 | } 112 | 113 | if ($offsetAndLimit['limit']) { 114 | $queryBuilder->setMaxResults($offsetAndLimit['limit']); 115 | } 116 | 117 | $edgesAndCursors = $this->buildEdgesAndCursors($queryBuilder, $offsetAndLimit, $paginationFields); 118 | 119 | return [ 120 | 'edges' => $edgesAndCursors['edges'], 121 | 'totalCount' => $edgesAndCursors['totalCount'], 122 | 'pageInfo' => [ 123 | 'endCursor' => $edgesAndCursors['cursors']['last'], 124 | 'startCursor' => $edgesAndCursors['cursors']['start'], 125 | 'hasNextPage' => $edgesAndCursors['cursors']['end'] !== $edgesAndCursors['cursors']['last'], 126 | 'hasPreviousPage' => $edgesAndCursors['cursors']['start'] !== base64_encode((string) 0), 127 | ], 128 | ]; 129 | } 130 | 131 | /** 132 | * @param array $offsetAndLimit 133 | * @param array $paginationFields 134 | * 135 | * @return array 136 | */ 137 | protected function buildEdgesAndCursors(QueryBuilder $queryBuilder, array $offsetAndLimit, array $paginationFields): array 138 | { 139 | $index = 0; 140 | $edges = []; 141 | $cursors = [ 142 | 'start' => base64_encode((string) 0), 143 | 'first' => null, 144 | 'last' => base64_encode((string) 0), 145 | ]; 146 | 147 | $paginator = new Paginator($queryBuilder->getQuery()); 148 | $itemCount = $paginator->count(); 149 | 150 | // Rebuild paginator if needed 151 | if ($paginationFields['last'] && ! $paginationFields['before']) { 152 | $offsetAndLimit['offset'] = $itemCount - $paginationFields['last']; 153 | $queryBuilder->setFirstResult($offsetAndLimit['offset']); 154 | $paginator = new Paginator($queryBuilder->getQuery()); 155 | } 156 | 157 | $startCursor = null; 158 | foreach ($paginator->getQuery()->getResult() as $result) { 159 | $cursors['last'] = base64_encode((string) ($index + $offsetAndLimit['offset'])); 160 | 161 | $edges[] = [ 162 | 'node' => $result, 163 | 'cursor' => $cursors['last'], 164 | ]; 165 | 166 | if (! $startCursor) { 167 | $startCursor = $cursors['last']; 168 | } 169 | 170 | if (! $cursors['first']) { 171 | $cursors['first'] = $cursors['last']; 172 | } 173 | 174 | $index++; 175 | } 176 | 177 | $endIndex = $paginator->count() ? $paginator->count() - 1 : 0; 178 | $cursors['end'] = base64_encode((string) $endIndex); 179 | $cursors['start'] = $startCursor ?? $cursors['start']; 180 | 181 | return [ 182 | 'cursors' => $cursors, 183 | 'edges' => $edges, 184 | 'totalCount' => $paginator->count(), 185 | ]; 186 | } 187 | 188 | /** 189 | * @param array $paginationFields 190 | * 191 | * @return array 192 | */ 193 | protected function calculateOffsetAndLimit(Entity $entity, array $paginationFields): array 194 | { 195 | $offset = 0; 196 | 197 | $limit = $this->metadata[$entity->getEntityClass()]['limit']; 198 | 199 | if (! $limit) { 200 | $limit = $this->config->getLimit(); 201 | } 202 | 203 | $adjustedLimit = $paginationFields['first'] ?: $paginationFields['last'] ?: $limit; 204 | if ($adjustedLimit < $limit) { 205 | $limit = $adjustedLimit; 206 | } 207 | 208 | if ($paginationFields['after']) { 209 | $offset = $paginationFields['after']; 210 | } elseif ($paginationFields['before']) { 211 | $offset = $paginationFields['before'] - $limit; 212 | } 213 | 214 | if ($offset < 0) { 215 | $limit += $offset; 216 | $offset = 0; 217 | } 218 | 219 | return [ 220 | 'offset' => $offset, 221 | 'limit' => $limit, 222 | ]; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/Services.php: -------------------------------------------------------------------------------- 1 | set(EntityManager::class, $entityManager) 30 | ->set( 31 | Config::class, 32 | static function () use ($config) { 33 | if (! $config) { 34 | $config = new Config(); 35 | } 36 | 37 | return $config; 38 | }, 39 | ) 40 | ->set(EventDispatcher::class, static fn () => new EventDispatcher()) 41 | ->set(Type\TypeContainer::class, static fn () => new Type\TypeContainer()) 42 | ->set( 43 | Type\Entity\EntityTypeContainer::class, 44 | static fn (Container $container) => new Type\Entity\EntityTypeContainer($container), 45 | ) 46 | ->set( 47 | 'metadata', 48 | static function (Container $container) use ($metadata) { 49 | return (new Metadata\MetadataFactory( 50 | $metadata, 51 | $container->get(EntityManager::class), 52 | $container->get(Config::class), 53 | $container->get(GlobalEnable::class), 54 | $container->get(EventDispatcher::class), 55 | ))(); 56 | }, 57 | ) 58 | ->set( 59 | Metadata\GlobalEnable::class, 60 | static function (Container $container) { 61 | return new Metadata\GlobalEnable( 62 | $container->get(EntityManager::class), 63 | $container->get(Config::class), 64 | $container->get(EventDispatcher::class), 65 | ); 66 | }, 67 | ) 68 | ->set( 69 | Resolve\FieldResolver::class, 70 | static function (Container $container) { 71 | return new Resolve\FieldResolver( 72 | $container->get(Config::class), 73 | $container->get(Type\Entity\EntityTypeContainer::class), 74 | ); 75 | }, 76 | ) 77 | ->set( 78 | Resolve\ResolveCollectionFactory::class, 79 | static function (Container $container) { 80 | return new Resolve\ResolveCollectionFactory( 81 | $container->get(EntityManager::class), 82 | $container->get(Config::class), 83 | $container->get(Resolve\FieldResolver::class), 84 | $container->get(Type\TypeContainer::class), 85 | $container->get(EntityTypeContainer::class), 86 | $container->get(EventDispatcher::class), 87 | $container->get('metadata'), 88 | ); 89 | }, 90 | ) 91 | ->set( 92 | Resolve\ResolveEntityFactory::class, 93 | static function (Container $container) { 94 | return new Resolve\ResolveEntityFactory( 95 | $container->get(Config::class), 96 | $container->get(EntityManager::class), 97 | $container->get(EventDispatcher::class), 98 | $container->get('metadata'), 99 | ); 100 | }, 101 | ) 102 | ->set( 103 | Filter\FilterFactory::class, 104 | static function (Container $container) { 105 | return new Filter\FilterFactory( 106 | $container->get(Config::class), 107 | $container->get(EntityManager::class), 108 | $container->get(Type\TypeContainer::class), 109 | $container->get(EventDispatcher::class), 110 | ); 111 | }, 112 | ) 113 | ->set( 114 | Hydrator\HydratorContainer::class, 115 | static function (Container $container) { 116 | return new Hydrator\HydratorContainer( 117 | $container->get(EntityManager::class), 118 | $container->get(Type\Entity\EntityTypeContainer::class), 119 | ); 120 | }, 121 | ) 122 | ->set( 123 | Input\InputFactory::class, 124 | static function (Container $container) { 125 | return new Input\InputFactory( 126 | $container->get(Config::class), 127 | $container->get(EntityManager::class), 128 | $container->get(Type\Entity\EntityTypeContainer::class), 129 | $container->get(Type\TypeContainer::class), 130 | ); 131 | }, 132 | ); 133 | } 134 | 135 | abstract public function set(string $id, mixed $value): mixed; 136 | } 137 | -------------------------------------------------------------------------------- /src/Type/Blob.php: -------------------------------------------------------------------------------- 1 | kind, $valueNode); 30 | } 31 | 32 | // @codeCoverageIgnoreEnd 33 | 34 | return $this->parseValue($valueNode->value); 35 | } 36 | 37 | public function parseValue(mixed $value): mixed 38 | { 39 | if (! is_string($value)) { 40 | throw new Error('Blob field as base64 is not a string: ' . $value); 41 | } 42 | 43 | $data = base64_decode($value, true); 44 | 45 | if ($data === false) { 46 | throw new Error('Blob field contains non-base64 encoded characters'); 47 | } 48 | 49 | return $data; 50 | } 51 | 52 | public function serialize(mixed $value): mixed 53 | { 54 | if (! $value) { 55 | return $value; 56 | } 57 | 58 | if (is_resource($value)) { 59 | $value = stream_get_contents($value); 60 | } 61 | 62 | return base64_encode($value); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Type/Connection.php: -------------------------------------------------------------------------------- 1 | 'Connection_' . $typeName, 28 | 'description' => 'Connection for ' . $typeName, 29 | 'fields' => [ 30 | 'edges' => Type::listOf($container 31 | ->build(Node::class, 'Node_' . $typeName, $objectType)), 32 | 'totalCount' => Type::nonNull(Type::int()), 33 | 'pageInfo' => $container->get('PageInfo'), 34 | ], 35 | ]; 36 | 37 | parent::__construct($configuration); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Type/Date.php: -------------------------------------------------------------------------------- 1 | kind, $valueNode); 29 | } 30 | 31 | // @codeCoverageIgnoreEnd 32 | 33 | return $this->parseValue($valueNode->value); 34 | } 35 | 36 | public function parseValue(mixed $value): DateTime 37 | { 38 | if (! is_string($value)) { 39 | throw new Error('Date is not a string: ' . $value); 40 | } 41 | 42 | if (! preg_match('/^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])$/', $value)) { 43 | throw new Error('Date format does not match Y-m-d e.g. 2004-02-12.'); 44 | } 45 | 46 | return DateTime::createFromFormat(DateTime::ATOM, $value . 'T00:00:00+00:00'); 47 | } 48 | 49 | public function serialize(mixed $value): string|null 50 | { 51 | if (is_string($value)) { 52 | throw new Error('Expected DateTime object. Got string.'); 53 | } 54 | 55 | if (! $value instanceof DateTime) { 56 | throw new Error('Expected DateTime object. Got ' . $value::class); 57 | } 58 | 59 | return $value->format('Y-m-d'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Type/DateImmutable.php: -------------------------------------------------------------------------------- 1 | kind, $valueNode); 29 | } 30 | 31 | // @codeCoverageIgnoreEnd 32 | 33 | return $this->parseValue($valueNode->value); 34 | } 35 | 36 | public function parseValue(mixed $value): DateTimeImmutable|false 37 | { 38 | if (! is_string($value)) { 39 | throw new Error('Date is not a string: ' . $value); 40 | } 41 | 42 | if (! preg_match('/^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])$/', $value)) { 43 | throw new Error('Date format does not match Y-m-d e.g. 2004-02-12.'); 44 | } 45 | 46 | return DateTimeImmutable::createFromFormat(DateTimeImmutable::ATOM, $value . 'T00:00:00+00:00'); 47 | } 48 | 49 | public function serialize(mixed $value): string|null 50 | { 51 | if (is_string($value)) { 52 | throw new Error('Expected DateTimeImmutable object. Got string.'); 53 | } 54 | 55 | if (! $value instanceof DateTimeImmutable) { 56 | throw new Error('Expected DateTimeImmutable object. Got ' . $value::class); 57 | } 58 | 59 | return $value->format('Y-m-d'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Type/DateTime.php: -------------------------------------------------------------------------------- 1 | kind, $valueNode); 29 | } 30 | 31 | // @codeCoverageIgnoreEnd 32 | 33 | return $this->parseValue($valueNode->value); 34 | } 35 | 36 | public function parseValue(mixed $value): PHPDateTime 37 | { 38 | if (! is_string($value)) { 39 | throw new Error('datetime is not a string: ' . $value); 40 | } 41 | 42 | $data = PHPDateTime::createFromFormat(PHPDateTime::ATOM, $value); 43 | 44 | if ($data === false) { 45 | throw new Error('datetime format does not match ISO 8601.'); 46 | } 47 | 48 | return $data; 49 | } 50 | 51 | public function serialize(mixed $value): string|null 52 | { 53 | if ($value instanceof PHPDateTime) { 54 | $value = $value->format(PHPDateTime::ATOM); 55 | } 56 | 57 | return $value; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Type/DateTimeImmutable.php: -------------------------------------------------------------------------------- 1 | kind, $valueNode); 29 | } 30 | 31 | // @codeCoverageIgnoreEnd 32 | 33 | return $this->parseValue($valueNode->value); 34 | } 35 | 36 | public function parseValue(mixed $value): PHPDateTimeImmutable 37 | { 38 | if (! is_string($value)) { 39 | throw new Error('datetime_immutable is not a string: ' . $value); 40 | } 41 | 42 | $data = PHPDateTimeImmutable::createFromFormat(PHPDateTimeImmutable::ATOM, $value); 43 | 44 | if ($data === false) { 45 | throw new Error('datetime_immutable format does not match ISO 8601.'); 46 | } 47 | 48 | return $data; 49 | } 50 | 51 | public function serialize(mixed $value): string|null 52 | { 53 | if ($value instanceof PHPDateTimeImmutable) { 54 | $value = $value->format(PHPDateTimeImmutable::ATOM); 55 | } 56 | 57 | return $value; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Type/DateTimeTZ.php: -------------------------------------------------------------------------------- 1 | kind, $valueNode); 29 | } 30 | 31 | // @codeCoverageIgnoreEnd 32 | 33 | return $this->parseValue($valueNode->value); 34 | } 35 | 36 | public function parseValue(mixed $value): PHPDateTimeTZ 37 | { 38 | if (! is_string($value)) { 39 | throw new Error('datetimetz is not a string: ' . $value); 40 | } 41 | 42 | $data = PHPDateTimeTZ::createFromFormat(PHPDateTimeTZ::ATOM, $value); 43 | 44 | if ($data === false) { 45 | throw new Error('datetimetz format does not match ISO 8601.'); 46 | } 47 | 48 | return $data; 49 | } 50 | 51 | public function serialize(mixed $value): string|null 52 | { 53 | if ($value instanceof PHPDateTimeTZ) { 54 | $value = $value->format(PHPDateTimeTZ::ATOM); 55 | } 56 | 57 | return $value; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Type/DateTimeTZImmutable.php: -------------------------------------------------------------------------------- 1 | kind, $valueNode); 29 | } 30 | 31 | // @codeCoverageIgnoreEnd 32 | 33 | return $this->parseValue($valueNode->value); 34 | } 35 | 36 | public function parseValue(mixed $value): PHPDateTimeTZImmutable 37 | { 38 | if (! is_string($value)) { 39 | throw new Error('datetimetz_immutable is not a string: ' . $value); 40 | } 41 | 42 | $data = PHPDateTimeTZImmutable::createFromFormat(PHPDateTimeTZImmutable::ATOM, $value); 43 | 44 | if ($data === false) { 45 | throw new Error('datetimetz_immutable format does not match ISO 8601.'); 46 | } 47 | 48 | return $data; 49 | } 50 | 51 | public function serialize(mixed $value): string|null 52 | { 53 | if ($value instanceof PHPDateTimeTZImmutable) { 54 | $value = $value->format(PHPDateTimeTZImmutable::ATOM); 55 | } 56 | 57 | return $value; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Type/Entity/Entity.php: -------------------------------------------------------------------------------- 1 | */ 46 | protected array $extractionMap = []; 47 | protected ObjectType|null $objectType = null; 48 | protected readonly Config $config; 49 | protected readonly EntityManager $entityManager; 50 | protected readonly EntityTypeContainer $entityTypeContainer; 51 | protected readonly EventDispatcher $eventDispatcher; 52 | protected readonly FieldResolver $fieldResolver; 53 | protected readonly FilterFactory $filterFactory; 54 | protected readonly HydratorContainer $hydratorContainer; 55 | protected readonly ResolveCollectionFactory $resolveCollectionFactory; 56 | protected readonly TypeContainer $typeContainer; 57 | 58 | public function __construct( 59 | Container $container, 60 | string $typeName, 61 | private string|null $eventName = null, 62 | ) { 63 | assert($container instanceof Driver); 64 | 65 | $this->config = $container->get(Config::class); 66 | $this->entityManager = $container->get(EntityManager::class); 67 | $this->entityTypeContainer = $container->get(EntityTypeContainer::class); 68 | $this->eventDispatcher = $container->get(EventDispatcher::class); 69 | $this->fieldResolver = $container->get(FieldResolver::class); 70 | $this->filterFactory = $container->get(FilterFactory::class); 71 | $this->hydratorContainer = $container->get(HydratorContainer::class); 72 | $this->resolveCollectionFactory = $container->get(ResolveCollectionFactory::class); 73 | $this->typeContainer = $container->get(TypeContainer::class); 74 | 75 | if (! isset($container->get('metadata')[$typeName])) { 76 | throw new Error( 77 | 'Entity ' . $typeName . ' is not mapped in the GraphQL metadata', 78 | ); 79 | } 80 | 81 | $this->metadata = $container->get('metadata')[$typeName]; 82 | } 83 | 84 | public function getHydrator(): HydratorInterface 85 | { 86 | return $this->hydratorContainer->get($this->getEntityClass()); 87 | } 88 | 89 | public function getTypeName(): string 90 | { 91 | return $this->metadata['typeName']; 92 | } 93 | 94 | public function getDescription(): string|null 95 | { 96 | return $this->metadata['description']; 97 | } 98 | 99 | /** @return mixed[] */ 100 | public function getMetadata(): array 101 | { 102 | return $this->metadata; 103 | } 104 | 105 | /** @return class-string */ 106 | public function getEntityClass(): string 107 | { 108 | return $this->metadata['entityClass']; 109 | } 110 | 111 | /** 112 | * An extraction map is used to alias fields and associations using a 113 | * naming strategy in the hydrator 114 | * 115 | * @return array 116 | */ 117 | public function getExtractionMap(): array 118 | { 119 | if (count($this->extractionMap)) { 120 | return $this->extractionMap; 121 | } 122 | 123 | foreach ($this->metadata['fields'] as $fieldName => $fieldMetadata) { 124 | if (! isset($fieldMetadata['alias'])) { 125 | continue; 126 | } 127 | 128 | // Don't allow duplicate aliases 129 | if (in_array($fieldMetadata['alias'], $this->extractionMap)) { 130 | throw new Exception('Duplicate alias found for field ' . $fieldName); 131 | } 132 | 133 | $this->extractionMap[$fieldName] = $fieldMetadata['alias']; 134 | } 135 | 136 | return $this->extractionMap; 137 | } 138 | 139 | /** 140 | * Build the type for the current entity 141 | * 142 | * @throws MappingException 143 | */ 144 | public function getObjectType(): ObjectType 145 | { 146 | // The result of this function is cached in the objectType property. 147 | // Entity object types are not stored in the TypeContainer 148 | if ($this->objectType) { 149 | return $this->objectType; 150 | } 151 | 152 | $fields = $this->addFields(); 153 | $fields = array_merge($fields, $this->addAssociations()); 154 | 155 | $typeName = $this->getTypeName(); 156 | if ($this->eventName) { 157 | $typeName .= '.' . $this->eventName; 158 | } 159 | 160 | /** @var ArrayObject<'description'|'fields'|'name'|'resolveField', mixed> $arrayObject */ 161 | $arrayObject = new ArrayObject([ 162 | 'name' => $typeName, 163 | 'description' => $this->getDescription(), 164 | 'fields' => static fn () => $fields, 165 | 'resolveField' => $this->fieldResolver, 166 | ]); 167 | 168 | /** 169 | * Dispatch event to allow modifications to the ObjectType definition 170 | */ 171 | $this->eventDispatcher->dispatch( 172 | new EntityDefinition($arrayObject, $this->eventName ??= $this->getEntityClass() . '.definition'), 173 | ); 174 | 175 | /** 176 | * If sortFields then resolve the fields and sort them 177 | */ 178 | if ($this->config->getSortFields()) { 179 | if ($arrayObject['fields'] instanceof Closure) { 180 | $arrayObject['fields'] = $arrayObject['fields'](); 181 | } 182 | 183 | ksort($arrayObject['fields'], SORT_REGULAR); 184 | } 185 | 186 | /** @psalm-suppress InvalidArgument */ 187 | $this->objectType = new ObjectType($arrayObject->getArrayCopy()); 188 | 189 | return $this->objectType; 190 | } 191 | 192 | /** @return array */ 193 | protected function addFields(): array 194 | { 195 | $fields = []; 196 | 197 | $classMetadata = $this->entityManager->getClassMetadata($this->getEntityClass()); 198 | 199 | foreach ($classMetadata->getFieldNames() as $fieldName) { 200 | if (! in_array($fieldName, array_keys($this->metadata['fields']))) { 201 | continue; 202 | } 203 | 204 | $fields[$this->getExtractionMap()[$fieldName] ?? $fieldName] = [ 205 | 'type' => $this->typeContainer 206 | ->get($this->getmetadata()['fields'][$fieldName]['type']), 207 | 'description' => $this->metadata['fields'][$fieldName]['description'], 208 | ]; 209 | } 210 | 211 | return $fields; 212 | } 213 | 214 | /** @return array */ 215 | protected function addAssociations(): array 216 | { 217 | $fields = []; 218 | 219 | $classMetadata = $this->entityManager->getClassMetadata($this->getEntityClass()); 220 | 221 | foreach ($classMetadata->getAssociationNames() as $associationName) { 222 | if (! in_array($associationName, array_keys($this->metadata['fields']))) { 223 | continue; 224 | } 225 | 226 | $associationMetadata = $classMetadata->getAssociationMapping($associationName); 227 | if ( 228 | in_array($associationMetadata['type'], [ 229 | ClassMetadata::ONE_TO_ONE, 230 | ClassMetadata::MANY_TO_ONE, 231 | ClassMetadata::TO_ONE, 232 | ]) 233 | ) { 234 | $targetEntity = $associationMetadata['targetEntity']; 235 | $fields[$associationName] = function () use ($targetEntity) { 236 | $entity = $this->entityTypeContainer->get($targetEntity); 237 | 238 | return [ 239 | 'type' => $entity->getObjectType(), 240 | 'description' => $entity->getDescription(), 241 | ]; 242 | }; 243 | 244 | continue; 245 | } 246 | 247 | // Collections 248 | $targetEntity = $associationMetadata['targetEntity']; 249 | 250 | $fields[$this->getExtractionMap()[$associationName] ?? $associationName] = function () use ($targetEntity, $associationName) { 251 | $entity = $this->entityTypeContainer->get($targetEntity); 252 | $shortName = $this->getTypeName() . '_' . ucwords($associationName); 253 | 254 | return [ 255 | 'type' => $this->typeContainer->build( 256 | Connection::class, 257 | $shortName, 258 | $entity->getObjectType(), 259 | ), 260 | 'args' => [ 261 | 'filter' => $this->filterFactory->get( 262 | $entity, 263 | $this, 264 | $associationName, 265 | $this->metadata['fields'][$associationName], 266 | ), 267 | 'pagination' => $this->typeContainer->get('pagination'), 268 | ], 269 | 'description' => $this->metadata['fields'][$associationName]['description'], 270 | 'resolve' => $this->resolveCollectionFactory->get($entity), 271 | ]; 272 | }; 273 | } 274 | 275 | return $fields; 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/Type/Entity/EntityTypeContainer.php: -------------------------------------------------------------------------------- 1 | container->get('metadata')[$id]); 31 | } 32 | 33 | /** 34 | * Create and return an Entity object 35 | */ 36 | public function get(string $id, string|null $eventName = null): mixed 37 | { 38 | // Allow for entities with a custom eventName 39 | $key = strtolower($id . ($eventName ? '.' . $eventName : '')); 40 | 41 | if (isset($this->register[$key])) { 42 | return $this->register[$key]; 43 | } 44 | 45 | $this->set($key, new Entity($this->container, $id, $eventName)); 46 | 47 | return $this->get($id, $eventName); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Type/Json.php: -------------------------------------------------------------------------------- 1 | kind, $valueNode); 29 | } 30 | 31 | return $valueNode->value; 32 | // @codeCoverageIgnoreEnd 33 | } 34 | 35 | /** 36 | * @return mixed[]|null 37 | * 38 | * @throws Error 39 | */ 40 | public function parseValue(mixed $value): array|null 41 | { 42 | if (! is_string($value)) { 43 | throw new Error('JSON is not a string: ' . $value); 44 | } 45 | 46 | $data = json_decode($value, true); 47 | 48 | if (! $data) { 49 | throw new Error('Could not parse JSON data'); 50 | } 51 | 52 | return $data; 53 | } 54 | 55 | public function serialize(mixed $value): string|null 56 | { 57 | return json_encode($value); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Type/Node.php: -------------------------------------------------------------------------------- 1 | $typeName, 28 | 'fields' => [ 29 | 'node' => $params[0], 30 | 'cursor' => Type::nonNull(Type::string()), 31 | ], 32 | ]; 33 | 34 | parent::__construct($configuration); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Type/PageInfo.php: -------------------------------------------------------------------------------- 1 | 'PageInfo', 19 | 'description' => 'Page information', 20 | 'fields' => [ 21 | 'startCursor' => [ 22 | 'description' => 'Cursor corresponding to the first node in edges.', 23 | 'type' => Type::nonNull(Type::string()), 24 | ], 25 | 'endCursor' => [ 26 | 'description' => 'Cursor corresponding to the last node in edges.', 27 | 'type' => Type::nonNull(Type::string()), 28 | ], 29 | 'hasPreviousPage' => [ 30 | 'description' => 'If edges contains more than last elements return true, otherwise false.', 31 | 'type' => Type::nonNull(Type::boolean()), 32 | ], 33 | 'hasNextPage' => [ 34 | 'description' => 'If edges contains more than first elements return true, otherwise false.', 35 | 'type' => Type::nonNull(Type::boolean()), 36 | ], 37 | ], 38 | ]; 39 | 40 | parent::__construct($configuration); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Type/Pagination.php: -------------------------------------------------------------------------------- 1 | 'Pagination', 19 | 'description' => 'Pagination fields for the GraphQL Complete Connection Model', 20 | 'fields' => [ 21 | 'first' => [ 22 | 'type' => Type::int(), 23 | 'description' => 'Takes a non-negative integer.', 24 | ], 25 | 'after' => [ 26 | 'type' => Type::string(), 27 | 'description' => 'Takes the cursor type.', 28 | ], 29 | 'last' => [ 30 | 'type' => Type::int(), 31 | 'description' => 'Takes a non-negative integer.', 32 | ], 33 | 'before' => [ 34 | 'type' => Type::string(), 35 | 'description' => 'Takes the cursor type.', 36 | ], 37 | ], 38 | ]; 39 | 40 | parent::__construct($configuration); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Type/Time.php: -------------------------------------------------------------------------------- 1 | kind, $valueNode); 30 | } 31 | 32 | // @codeCoverageIgnoreEnd 33 | 34 | return $valueNode->value; 35 | } 36 | 37 | /** 38 | * Parse H:i:s.u and H:i:s 39 | */ 40 | public function parseValue(mixed $value): PHPDateTime 41 | { 42 | if (! is_string($value)) { 43 | throw new Error('Time is not a string: ' . $value); 44 | } 45 | 46 | if (! preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])(\.\d{1,6})?$/', $value)) { 47 | throw new Error('Time ' . $value . ' format does not match H:i:s.u e.g. 13:34:40.867530'); 48 | } 49 | 50 | // If time does not have milliseconds, parse without 51 | if (preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])$/', $value)) { 52 | return PHPDateTime::createFromFormat('H:i:s', $value); 53 | } 54 | 55 | return PHPDateTime::createFromFormat('H:i:s.u', $value); 56 | } 57 | 58 | public function serialize(mixed $value): string|null 59 | { 60 | if ($value instanceof PHPDateTime) { 61 | $value = $value->format('H:i:s.u'); 62 | } 63 | 64 | return $value; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Type/TimeImmutable.php: -------------------------------------------------------------------------------- 1 | kind, $valueNode); 30 | } 31 | 32 | // @codeCoverageIgnoreEnd 33 | 34 | return $valueNode->value; 35 | } 36 | 37 | public function parseValue(mixed $value): PHPDateTime|false 38 | { 39 | if (! is_string($value)) { 40 | throw new Error('Time is not a string: ' . $value); 41 | } 42 | 43 | if (! preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])(\.\d{1,6})?$/', $value)) { 44 | throw new Error('Time ' . $value . ' format does not match H:i:s.u e.g. 13:34:40.867530'); 45 | } 46 | 47 | // If time does not have milliseconds, parse without 48 | if (preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])$/', $value)) { 49 | return PHPDateTime::createFromFormat('H:i:s', $value); 50 | } 51 | 52 | return PHPDateTime::createFromFormat('H:i:s.u', $value); 53 | } 54 | 55 | public function serialize(mixed $value): string|null 56 | { 57 | if ($value instanceof PHPDateTime) { 58 | $value = $value->format('H:i:s.u'); 59 | } 60 | 61 | return $value; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Type/TypeContainer.php: -------------------------------------------------------------------------------- 1 | set('tinyint', static fn () => Type::int()) 19 | ->set('smallint', static fn () => Type::int()) 20 | ->set('integer', static fn () => Type::int()) 21 | ->set('int', static fn () => Type::int()) 22 | ->set('boolean', static fn () => Type::boolean()) 23 | ->set('decimal', static fn () => Type::float()) 24 | ->set('float', static fn () => Type::float()) 25 | ->set('bigint', static fn () => Type::string()) 26 | ->set('string', static fn () => Type::string()) 27 | ->set('text', static fn () => Type::string()) 28 | ->set('simple_array', static fn () => Type::listOf(Type::string())) 29 | ->set('json', static fn () => new Json()) 30 | ->set('date', static fn () => new Date()) 31 | ->set('datetime', static fn () => new DateTime()) 32 | ->set('datetimetz', static fn () => new DateTimeTZ()) 33 | ->set('time', static fn () => new Time()) 34 | ->set('date_immutable', static fn () => new DateImmutable()) 35 | ->set('datetime_immutable', static fn () => new DateTimeImmutable()) 36 | ->set('datetimetz_immutable', static fn () => new DateTimeTZImmutable()) 37 | ->set('time_immutable', static fn () => new TimeImmutable()) 38 | ->set('pageinfo', static fn () => new PageInfo()) 39 | ->set('pagination', static fn () => new Pagination()) 40 | ->set('blob', static fn () => new Blob()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /testdatabase.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Skeletons/doctrine-orm-graphql/7a953cf60af7a1ad593d1030b806e197babb21c5/testdatabase.sqlite --------------------------------------------------------------------------------