├── .php-cs-fixer.dist.php ├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock ├── phpstan-baseline.neon └── src ├── Attribute ├── AbstractAttribute.php ├── ApiAttribute.php ├── Argument.php ├── Exclude.php ├── Field.php ├── Filter.php ├── FilterGroupCondition.php ├── Input.php ├── Reader │ └── Reader.php └── Sorting.php ├── DefaultFieldResolver.php ├── Definition ├── EntityID.php ├── EntityIDType.php ├── JoinTypeType.php ├── LogicalOperatorType.php ├── Operator │ ├── AbstractAssociationOperatorType.php │ ├── AbstractOperator.php │ ├── AbstractSimpleOperator.php │ ├── BetweenOperatorType.php │ ├── EmptyOperatorType.php │ ├── EqualOperatorType.php │ ├── GreaterOperatorType.php │ ├── GreaterOrEqualOperatorType.php │ ├── GroupOperatorType.php │ ├── HaveOperatorType.php │ ├── InOperatorType.php │ ├── LessOperatorType.php │ ├── LessOrEqualOperatorType.php │ ├── LikeOperatorType.php │ └── NullOperatorType.php └── SortingOrderType.php ├── DocBlockReader.php ├── Exception.php ├── Factory ├── AbstractFactory.php ├── AbstractFieldsConfigurationFactory.php ├── FilteredQueryBuilderFactory.php ├── InputFieldsConfigurationFactory.php ├── OutputFieldsConfigurationFactory.php ├── Type │ ├── AbstractTypeFactory.php │ ├── EntityIDTypeFactory.php │ ├── FilterGroupConditionTypeFactory.php │ ├── FilterGroupJoinTypeFactory.php │ ├── FilterTypeFactory.php │ ├── InputTypeFactory.php │ ├── JoinOnTypeFactory.php │ ├── ObjectTypeFactory.php │ ├── PartialInputTypeFactory.php │ └── SortingTypeFactory.php └── UniqueNameFactory.php ├── Sorting └── SortingInterface.php ├── Types.php ├── TypesInterface.php └── Utils.php /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | exclude('vendor') 7 | ->in(__DIR__); 8 | 9 | return (new PhpCsFixer\Config()) 10 | ->setRiskyAllowed(true) 11 | ->setFinder($finder) 12 | ->setCacheFile(sys_get_temp_dir() . '/php-cs-fixer' . preg_replace('~\W~', '-', __DIR__)) 13 | ->setRules([ 14 | 'align_multiline_comment' => true, 15 | 'array_indentation' => true, 16 | 'array_push' => true, 17 | 'array_syntax' => ['syntax' => 'short'], 18 | 'assign_null_coalescing_to_coalesce_equal' => true, 19 | 'backtick_to_shell_exec' => true, 20 | 'binary_operator_spaces' => true, 21 | 'blank_line_after_namespace' => true, 22 | 'blank_line_after_opening_tag' => true, 23 | 'blank_line_before_statement' => true, 24 | 'braces' => true, 25 | 'cast_spaces' => true, 26 | 'class_attributes_separation' => ['elements' => ['method' => 'one', 'property' => 'one']], // const are often grouped with other related const 27 | 'class_definition' => true, 28 | 'class_keyword_remove' => false, // Deprecated, and ::class keyword gives us better support in IDE 29 | 'combine_consecutive_issets' => true, 30 | 'combine_consecutive_unsets' => true, 31 | 'combine_nested_dirname' => true, 32 | 'comment_to_phpdoc' => true, 33 | 'compact_nullable_typehint' => true, 34 | 'concat_space' => ['spacing' => 'one'], 35 | 'constant_case' => true, 36 | 'control_structure_continuation_position' => true, 37 | 'date_time_immutable' => true, 38 | 'declare_equal_normalize' => true, 39 | 'declare_parentheses' => true, 40 | 'declare_strict_types' => true, 41 | 'dir_constant' => true, 42 | 'doctrine_annotation_array_assignment' => true, 43 | 'doctrine_annotation_braces' => true, 44 | 'doctrine_annotation_indentation' => true, 45 | 'doctrine_annotation_spaces' => true, 46 | 'echo_tag_syntax' => true, 47 | 'elseif' => true, 48 | 'empty_loop_body' => true, 49 | 'empty_loop_condition' => true, 50 | 'encoding' => true, 51 | 'ereg_to_preg' => true, 52 | 'error_suppression' => true, 53 | 'escape_implicit_backslashes' => true, 54 | 'explicit_indirect_variable' => false, // I feel it makes the code actually harder to read 55 | 'explicit_string_variable' => false, // I feel it makes the code actually harder to read 56 | 'final_class' => false, // We need non-final classes 57 | 'final_internal_class' => true, 58 | 'final_public_method_for_abstract_class' => false, // We need non-final methods 59 | 'fopen_flag_order' => true, 60 | 'fopen_flags' => true, 61 | 'full_opening_tag' => true, 62 | 'fully_qualified_strict_types' => true, 63 | 'function_declaration' => true, 64 | 'function_to_constant' => true, 65 | 'function_typehint_space' => true, 66 | 'general_phpdoc_annotation_remove' => ['annotations' => ['author', 'category', 'copyright', 'package', 'throws']], 67 | 'general_phpdoc_tag_rename' => true, 68 | 'global_namespace_import' => true, 69 | 'group_import' => false, // I feel it makes the code actually harder to read 70 | 'header_comment' => false, // We don't use common header in all our files 71 | 'heredoc_indentation' => true, 72 | 'heredoc_to_nowdoc' => false, // We often use variable in heredoc 73 | 'implode_call' => true, 74 | 'include' => true, 75 | 'increment_style' => true, 76 | 'indentation_type' => true, 77 | 'integer_literal_case' => true, 78 | 'is_null' => true, 79 | 'lambda_not_used_import' => true, 80 | 'line_ending' => true, 81 | 'linebreak_after_opening_tag' => true, 82 | 'list_syntax' => ['syntax' => 'short'], 83 | 'logical_operators' => true, 84 | 'lowercase_cast' => true, 85 | 'lowercase_keywords' => true, 86 | 'lowercase_static_reference' => true, 87 | 'magic_constant_casing' => true, 88 | 'magic_method_casing' => true, 89 | 'mb_str_functions' => true, 90 | 'method_argument_space' => true, 91 | 'method_chaining_indentation' => true, 92 | 'modernize_strpos' => true, 93 | 'modernize_types_casting' => true, 94 | 'multiline_comment_opening_closing' => true, 95 | 'multiline_whitespace_before_semicolons' => true, 96 | 'native_constant_invocation' => false, // Micro optimization that look messy 97 | 'native_function_casing' => true, 98 | 'native_function_invocation' => false, // I suppose this would be best, but I am still unconvinced about the visual aspect of it 99 | 'native_function_type_declaration_casing' => true, 100 | 'new_with_braces' => true, 101 | 'no_alias_functions' => true, 102 | 'no_alias_language_construct_call' => true, 103 | 'no_alternative_syntax' => true, 104 | 'no_binary_string' => true, 105 | 'no_blank_lines_after_class_opening' => true, 106 | 'no_blank_lines_after_phpdoc' => true, 107 | 'no_blank_lines_before_namespace' => false, // we want 1 blank line before namespace 108 | 'no_break_comment' => true, 109 | 'no_closing_tag' => true, 110 | 'no_empty_comment' => true, 111 | 'no_empty_phpdoc' => true, 112 | 'no_empty_statement' => true, 113 | 'no_extra_blank_lines' => true, 114 | 'no_homoglyph_names' => true, 115 | 'no_leading_import_slash' => true, 116 | 'no_leading_namespace_whitespace' => true, 117 | 'no_mixed_echo_print' => true, 118 | 'no_multiline_whitespace_around_double_arrow' => true, 119 | 'no_null_property_initialization' => true, 120 | 'no_php4_constructor' => true, 121 | 'no_short_bool_cast' => true, 122 | 'no_singleline_whitespace_before_semicolons' => true, 123 | 'no_space_around_double_colon' => true, 124 | 'no_spaces_after_function_name' => true, 125 | 'no_spaces_around_offset' => true, 126 | 'no_spaces_inside_parenthesis' => true, 127 | 'no_superfluous_elseif' => true, 128 | 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true], 129 | 'no_trailing_comma_in_list_call' => true, 130 | 'no_trailing_comma_in_singleline_array' => true, 131 | 'no_trailing_whitespace' => true, 132 | 'no_trailing_whitespace_in_comment' => true, 133 | 'no_trailing_whitespace_in_string' => false, // Too dangerous 134 | 'no_unneeded_control_parentheses' => true, 135 | 'no_unneeded_curly_braces' => true, 136 | 'no_unneeded_final_method' => true, 137 | 'no_unreachable_default_argument_value' => true, 138 | 'no_unset_cast' => true, 139 | 'no_unset_on_property' => true, 140 | 'no_unused_imports' => true, 141 | 'no_useless_else' => true, 142 | 'no_useless_return' => true, 143 | 'no_useless_sprintf' => true, 144 | 'no_whitespace_before_comma_in_array' => true, 145 | 'no_whitespace_in_blank_line' => true, 146 | 'non_printable_character' => true, 147 | 'normalize_index_brace' => true, 148 | 'not_operator_with_space' => false, // No we prefer to keep '!' without spaces 149 | 'not_operator_with_successor_space' => false, // idem 150 | 'nullable_type_declaration_for_default_null_value' => true, 151 | 'object_operator_without_whitespace' => true, 152 | 'octal_notation' => true, 153 | 'operator_linebreak' => true, 154 | 'ordered_class_elements' => false, // We prefer to keep some freedom 155 | 'ordered_imports' => true, 156 | 'ordered_interfaces' => true, 157 | 'ordered_traits' => true, 158 | 'php_unit_construct' => true, 159 | 'php_unit_dedicate_assert' => true, 160 | 'php_unit_dedicate_assert_internal_type' => true, 161 | 'php_unit_expectation' => true, 162 | 'php_unit_fqcn_annotation' => true, 163 | 'php_unit_internal_class' => false, // Because tests are excluded from package 164 | 'php_unit_method_casing' => true, 165 | 'php_unit_mock' => true, 166 | 'php_unit_mock_short_will_return' => true, 167 | 'php_unit_namespaced' => true, 168 | 'php_unit_no_expectation_annotation' => true, 169 | 'php_unit_set_up_tear_down_visibility' => true, 170 | 'php_unit_size_class' => false, // That seems extra work to maintain for little benefits 171 | 'php_unit_strict' => false, // We sometime actually need assertEquals 172 | 'php_unit_test_annotation' => true, 173 | 'php_unit_test_case_static_method_calls' => ['call_type' => 'self'], 174 | 'php_unit_test_class_requires_covers' => false, // We don't care as much as we should about coverage 175 | 'phpdoc_add_missing_param_annotation' => true, 176 | 'phpdoc_align' => false, // Waste of time 177 | 'phpdoc_annotation_without_dot' => true, 178 | 'phpdoc_indent' => true, 179 | 'phpdoc_inline_tag_normalizer' => true, 180 | 'phpdoc_line_span' => true, 181 | 'phpdoc_no_access' => true, 182 | 'phpdoc_no_alias_tag' => true, 183 | 'phpdoc_no_empty_return' => true, 184 | 'phpdoc_no_package' => true, 185 | 'phpdoc_no_useless_inheritdoc' => true, 186 | 'phpdoc_order' => true, 187 | 'phpdoc_order_by_value' => true, 188 | 'phpdoc_return_self_reference' => true, 189 | 'phpdoc_scalar' => true, 190 | 'phpdoc_separation' => true, 191 | 'phpdoc_single_line_var_spacing' => true, 192 | 'phpdoc_summary' => true, 193 | 'phpdoc_tag_casing' => true, 194 | 'phpdoc_tag_type' => true, 195 | 'phpdoc_to_comment' => true, 196 | 'phpdoc_to_param_type' => false, // Because experimental, but interesting for one shot use 197 | 'phpdoc_to_property_type' => false, // Because experimental, but interesting for one shot use 198 | 'phpdoc_to_return_type' => false, // Because experimental, but interesting for one shot use 199 | 'phpdoc_trim' => true, 200 | 'phpdoc_trim_consecutive_blank_line_separation' => true, 201 | 'phpdoc_types' => true, 202 | 'phpdoc_types_order' => true, 203 | 'phpdoc_var_annotation_correct_order' => true, 204 | 'phpdoc_var_without_name' => true, 205 | 'pow_to_exponentiation' => true, 206 | 'protected_to_private' => true, 207 | 'psr_autoloading' => true, 208 | 'random_api_migration' => true, 209 | 'regular_callable_call' => true, 210 | 'return_assignment' => false, // Sometimes useful for clarity or debug 211 | 'return_type_declaration' => true, 212 | 'self_accessor' => true, 213 | 'self_static_accessor' => true, 214 | 'semicolon_after_instruction' => false, // We prefer to keep .phtml files without semicolon 215 | 'set_type_to_cast' => true, 216 | 'short_scalar_cast' => true, 217 | 'simple_to_complex_string_variable' => false, // Would differ from TypeScript without obvious advantages 218 | 'simplified_if_return' => false, // Even if technically correct we prefer to be explicit 219 | 'simplified_null_return' => false, // Even if technically correct we prefer to be explicit 220 | 'single_blank_line_at_eof' => true, 221 | 'single_blank_line_before_namespace' => true, 222 | 'single_class_element_per_statement' => true, 223 | 'single_import_per_statement' => true, 224 | 'single_line_after_imports' => true, 225 | 'single_line_comment_style' => true, 226 | 'single_line_throw' => false, // I don't see any reason for having a special case for Exception 227 | 'single_quote' => true, 228 | 'single_space_after_construct' => true, 229 | 'single_trait_insert_per_statement' => true, 230 | 'space_after_semicolon' => true, 231 | 'standardize_increment' => true, 232 | 'standardize_not_equals' => true, 233 | 'static_lambda' => false, // Risky if we can't guarantee nobody use `bindTo()` 234 | 'strict_comparison' => true, 235 | 'strict_param' => true, 236 | 'string_length_to_empty' => true, 237 | 'string_line_ending' => true, 238 | 'switch_case_semicolon_to_colon' => true, 239 | 'switch_case_space' => true, 240 | 'switch_continue_to_break' => true, 241 | 'ternary_operator_spaces' => true, 242 | 'ternary_to_elvis_operator' => true, 243 | 'ternary_to_null_coalescing' => true, 244 | 'trailing_comma_in_multiline' => true, 245 | 'trim_array_spaces' => true, 246 | 'types_spaces' => true, 247 | 'unary_operator_spaces' => true, 248 | 'use_arrow_functions' => true, 249 | 'visibility_required' => true, 250 | 'void_return' => true, 251 | 'whitespace_after_comma_in_array' => true, 252 | 'yoda_style' => false, 253 | ]); 254 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 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 | # GraphQL Doctrine 2 | 3 | [![Build Status](https://github.com/ecodev/graphql-doctrine/workflows/main/badge.svg)](https://github.com/ecodev/graphql-doctrine/actions) 4 | [![Code Quality](https://scrutinizer-ci.com/g/Ecodev/graphql-doctrine/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/Ecodev/graphql-doctrine/?branch=master) 5 | [![Code Coverage](https://scrutinizer-ci.com/g/Ecodev/graphql-doctrine/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/Ecodev/graphql-doctrine/?branch=master) 6 | [![Total Downloads](https://poser.pugx.org/ecodev/graphql-doctrine/downloads.png)](https://packagist.org/packages/ecodev/graphql-doctrine) 7 | [![Latest Stable Version](https://poser.pugx.org/ecodev/graphql-doctrine/v/stable.png)](https://packagist.org/packages/ecodev/graphql-doctrine) 8 | [![License](https://poser.pugx.org/ecodev/graphql-doctrine/license.png)](https://packagist.org/packages/ecodev/graphql-doctrine) 9 | [![Join the chat at https://gitter.im/Ecodev/graphql-doctrine](https://badges.gitter.im/Ecodev/graphql-doctrine.svg)](https://gitter.im/Ecodev/graphql-doctrine) 10 | 11 | A library to declare GraphQL types from Doctrine entities, PHP type hinting, 12 | and attributes, and to be used with [webonyx/graphql-php](https://github.com/webonyx/graphql-php). 13 | 14 | It reads most information from type hints, complete some things from existing 15 | Doctrine attributes and allow further customizations with specialized attributes. 16 | It will then create [`ObjectType`](https://webonyx.github.io/graphql-php/type-system/object-types/#object-type-definition) and 17 | [`InputObjectType`](https://webonyx.github.io/graphql-php/type-system/input-types/#input-object-type) 18 | instances with fields for all getter and setter respectively found on Doctrine entities. 19 | 20 | It will **not** build the entire schema. It is up to the user to use automated 21 | types, and other custom types, to define root queries. 22 | 23 | ## Quick start 24 | 25 | Install the library via composer: 26 | 27 | ```sh 28 | composer require ecodev/graphql-doctrine 29 | ``` 30 | 31 | And start using it: 32 | 33 | ```php 34 | [ 49 | DateTimeImmutable::class => DateTimeType::class, 50 | 'PostStatus' => PostStatusType::class, 51 | ], 52 | 'aliases' => [ 53 | 'datetime_immutable' => DateTimeImmutable::class, // Declare alias for Doctrine type to be used for filters 54 | ], 55 | ]); 56 | 57 | // Configure the type registry 58 | $types = new Types($entityManager, $customTypes); 59 | 60 | // Configure default field resolver to be able to use getters 61 | GraphQL::setDefaultFieldResolver(new DefaultFieldResolver()); 62 | 63 | // Build your Schema 64 | $schema = new Schema([ 65 | 'query' => new ObjectType([ 66 | 'name' => 'query', 67 | 'fields' => [ 68 | 'posts' => [ 69 | 'type' => Type::listOf($types->getOutput(Post::class)), // Use automated ObjectType for output 70 | 'args' => [ 71 | [ 72 | 'name' => 'filter', 73 | 'type' => $types->getFilter(Post::class), // Use automated filtering options 74 | ], 75 | [ 76 | 'name' => 'sorting', 77 | 'type' => $types->getSorting(Post::class), // Use automated sorting options 78 | ], 79 | ], 80 | 'resolve' => function ($root, $args) use ($types): void { 81 | $queryBuilder = $types->createFilteredQueryBuilder(Post::class, $args['filter'] ?? [], $args['sorting'] ?? []); 82 | 83 | // execute query... 84 | }, 85 | ], 86 | ], 87 | ]), 88 | 'mutation' => new ObjectType([ 89 | 'name' => 'mutation', 90 | 'fields' => [ 91 | 'createPost' => [ 92 | 'type' => Type::nonNull($types->getOutput(Post::class)), 93 | 'args' => [ 94 | 'input' => Type::nonNull($types->getInput(Post::class)), // Use automated InputObjectType for input 95 | ], 96 | 'resolve' => function ($root, $args): void { 97 | // create new post and flush... 98 | }, 99 | ], 100 | 'updatePost' => [ 101 | 'type' => Type::nonNull($types->getOutput(Post::class)), 102 | 'args' => [ 103 | 'id' => Type::nonNull(Type::id()), // Use standard API when needed 104 | 'input' => $types->getPartialInput(Post::class), // Use automated InputObjectType for partial input for updates 105 | ], 106 | 'resolve' => function ($root, $args): void { 107 | // update existing post and flush... 108 | }, 109 | ], 110 | ], 111 | ]), 112 | ]); 113 | ``` 114 | 115 | ## Usage 116 | 117 | The public API is limited to the public methods on `TypesInterface`, `Types`'s constructor, and the attributes. 118 | 119 | Here is a quick overview of `TypesInterface`: 120 | 121 | - `$types->get()` to get custom types 122 | - `$types->getOutput()` to get an `ObjectType` to be used in queries 123 | - `$types->getFilter()` to get an `InputObjectType` to be used in queries 124 | - `$types->getSorting()` to get an `InputObjectType` to be used in queries 125 | - `$types->getInput()` to get an `InputObjectType` to be used in mutations (typically for creation) 126 | - `$types->getPartialInput()` to get an `InputObjectType` to be used in mutations (typically for update) 127 | - `$types->getId()` to get an `EntityIDType` which may be used to receive an 128 | object from database instead of a scalar 129 | - `$types->has()` to check whether a type exists 130 | - `$types->createFilteredQueryBuilder()` to be used in query resolvers 131 | 132 | ### Information priority 133 | 134 | To avoid code duplication as much as possible, information are gathered from 135 | several places, where available. And each of those might be overridden. The order 136 | of priority, from the least to most important is: 137 | 138 | 1. Type hinting 139 | 2. Doc blocks 140 | 3. Attributes 141 | 142 | That means it is always possible to override everything with attributes. But 143 | existing type hints and dock blocks should cover the majority of cases. 144 | 145 | ### Exclude sensitive things 146 | 147 | All getters, and setters, are included by default in the type. And all properties are included in the filters. 148 | But it can be specified otherwise for each method and property. 149 | 150 | To exclude a sensitive field from ever being exposed through the API, use `#[API\Exclude]`: 151 | 152 | ```php 153 | use GraphQL\Doctrine\Attribute as API; 154 | 155 | /** 156 | * Returns the hashed password 157 | * 158 | * @return string 159 | */ 160 | #[API\Exclude] 161 | public function getPassword(): string 162 | { 163 | return $this->password; 164 | } 165 | ``` 166 | 167 | And to exclude a property from being exposed as a filter: 168 | 169 | ```php 170 | use GraphQL\Doctrine\Attribute as API; 171 | 172 | #[ORM\Column(name: 'password', type: 'string', length: 255)] 173 | #[API\Exclude] 174 | private string $password = ''; 175 | ``` 176 | 177 | ### Override output types 178 | 179 | Even if a getter returns a PHP scalar type, such as `string`, it might be preferable 180 | to override the type with a custom GraphQL type. This is typically useful for enum 181 | or other validation purposes, such as email address. This is done by specifying the 182 | GraphQL type FQCN via `#[API\Field]` attribute: 183 | 184 | ```php 185 | use GraphQL\Doctrine\Attribute as API; 186 | use GraphQLTests\Doctrine\Blog\Types\PostStatusType; 187 | 188 | /** 189 | * Get status 190 | * 191 | * @return string 192 | */ 193 | #[API\Field(type: PostStatusType::class)] 194 | public function getStatus(): string 195 | { 196 | return $this->status; 197 | } 198 | ``` 199 | 200 | ### Type syntax 201 | 202 | In most cases, the type must use the `::class` notation to specify the PHP class that is either implementing the GraphQL 203 | type or the entity itself (see [limitations](#limitations)). Use string literals only if you must define it as nullable 204 | and/or as an array. Never use the short name of an entity (it is only possible for user-defined custom types). 205 | 206 | Supported syntaxes (PHP style or GraphQL style) are: 207 | 208 | - `MyType::class` 209 | - `'?Application\MyType'` 210 | - `'null|Application\MyType'` 211 | - `'Application\MyType|null'` 212 | - `'Application\MyType[]'` 213 | - `'?Application\MyType[]'` 214 | - `'null|Application\MyType[]'` 215 | - `'Application\MyType[]|null'` 216 | - `'Collection'` 217 | 218 | This attribute can be used to override other things, such as `name`, `description` 219 | and `args`. 220 | 221 | ### Override arguments 222 | 223 | Similarly to `#[API\Field]`, `#[API\Argument]` allows to override the type of argument 224 | if the PHP type hint is not enough: 225 | 226 | ```php 227 | use GraphQL\Doctrine\Attribute as API; 228 | 229 | /** 230 | * Returns all posts of the specified status 231 | * 232 | * @param string $status the status of posts as defined in \GraphQLTests\Doctrine\Blog\Model\Post 233 | * 234 | * @return Collection 235 | */ 236 | public function getPosts( 237 | #[API\Argument(type: '?GraphQLTests\Doctrine\Blog\Types\PostStatusType')] 238 | ?string $status = Post::STATUS_PUBLIC 239 | ): Collection 240 | { 241 | // ... 242 | } 243 | ``` 244 | 245 | Once again, it also allows to override other things such as `name`, `description` 246 | and `defaultValue`. 247 | 248 | ### Override input types 249 | 250 | `#[API\Input]` is the opposite of `#[API\Field]` and can be used to override things for 251 | input types (setters), typically for validations purpose. This would look like: 252 | 253 | ```php 254 | use GraphQL\Doctrine\Attribute as API; 255 | use GraphQLTests\Doctrine\Blog\Types\PostStatusType; 256 | 257 | /** 258 | * Set status 259 | * 260 | * @param string $status 261 | */ 262 | #[API\Input(type: PostStatusType::class)] 263 | public function setStatus(string $status = self::STATUS_PUBLIC): void 264 | { 265 | $this->status = $status; 266 | } 267 | ``` 268 | 269 | This attribute also supports `description`, and `defaultValue`. 270 | 271 | ### Override filter types 272 | 273 | `#[API\FilterGroupCondition]` is the equivalent for filters that are generated from properties. 274 | So usage would be like: 275 | 276 | ```php 277 | use GraphQL\Doctrine\Attribute as API; 278 | 279 | #[API\FilterGroupCondition(type: '?GraphQLTests\Doctrine\Blog\Types\PostStatusType')] 280 | #[ORM\Column(type: 'string', options: ['default' => self::STATUS_PRIVATE])] 281 | private string $status = self::STATUS_PRIVATE; 282 | ``` 283 | 284 | An important thing to note is that the value of the type specified will be directly used in DQL. That means 285 | that if the value is not a PHP scalar, then it must be convertible to string via `__toString()`, or you have to 286 | do the conversion yourself before passing the filter values to `Types::createFilteredQueryBuilder()`. 287 | 288 | ### Custom types 289 | 290 | By default, all PHP scalar types and Doctrine collection are automatically detected 291 | and mapped to a GraphQL type. However, if some getter return custom types, such 292 | as `DateTimeImmutable`, or a custom class, then it will have to be configured beforehand. 293 | 294 | The configuration is done with a [PSR-11 container](https://www.php-fig.org/psr/psr-11/) 295 | implementation configured according to your needs. In the following example, we use 296 | [laminas/laminas-servicemanager](https://github.com/laminas/laminas-servicemanager), 297 | because it offers useful concepts such as: invokables, aliases, factories and abstract 298 | factories. But any other PSR-11 container implementation could be used instead. 299 | 300 | The keys should be the whatever you use to refer to the type in your model. Typically, 301 | that would be either the FQCN of a PHP class "native" type such as `DateTimeImmutable`, or the 302 | FQCN of a PHP class implementing the GraphQL type, or directly the GraphQL type name: 303 | 304 | ```php 305 | $customTypes = new ServiceManager([ 306 | 'invokables' => [ 307 | DateTimeImmutable::class => DateTimeType::class, 308 | 'PostStatus' => PostStatusType::class, 309 | ], 310 | ]); 311 | 312 | $types = new Types($entityManager, $customTypes); 313 | 314 | // Build schema... 315 | ``` 316 | 317 | That way it is not necessary to annotate every single getter returning one of the 318 | configured type. It will be mapped automatically. 319 | 320 | ### Entities as input arguments 321 | 322 | If a getter takes an entity as parameter, then a specialized `InputType` will 323 | be created automatically to accept an `ID`. The entity will then be automatically 324 | fetched from the database and forwarded to the getter. So this will work out of 325 | the box: 326 | 327 | ```php 328 | public function isAllowedEditing(User $user): bool 329 | { 330 | return $this->getUser() === $user; 331 | } 332 | ``` 333 | 334 | You may also get an input type for an entity by using `Types::getId()` to write 335 | things like: 336 | 337 | ```php 338 | [ 339 | // ... 340 | 'args' => [ 341 | 'id' => $types->getId(Post::class), 342 | ], 343 | 'resolve' => function ($root, array $args) { 344 | $post = $args['id']->getEntity(); 345 | 346 | // ... 347 | }, 348 | ] 349 | ``` 350 | 351 | ### Partial inputs 352 | 353 | In addition to normal input types, it is possible to get a partial input type via 354 | `getPartialInput()`. This is especially useful for mutations that update existing 355 | entities, when we do not want to have to re-submit all fields. By using a partial 356 | input, the API client is able to submit only the fields that need to be updated 357 | and nothing more. 358 | 359 | This potentially reduces network traffic, because the client does not need 360 | to fetch all fields just to be able re-submit them when he wants to modify only 361 | one field. 362 | 363 | And it also enables to easily design mass editing mutations where the client would 364 | submit only a few fields to be updated for many entities at once. This could look like: 365 | 366 | ```php 367 | [ 371 | 'type' => Type::nonNull(Type::listOf(Type::nonNull($types->get(Post::class)))), 372 | 'args' => [ 373 | 'ids' => Type::nonNull(Type::listOf(Type::nonNull(Type::id()))), 374 | 'input' => $types->getPartialInput(Post::class), // Use automated InputObjectType for partial input for updates 375 | ], 376 | 'resolve' => function ($root, $args) { 377 | // update existing posts and flush... 378 | } 379 | ], 380 | ]; 381 | ``` 382 | 383 | ### Default values 384 | 385 | Default values are automatically detected from arguments for getters, as seen in 386 | `getPosts()` example above. 387 | 388 | For setters, the default value will be looked up on the mapped property, if there is 389 | one matching the setter name. But if the setter itself has an argument with a default 390 | value, it will take precedence. 391 | 392 | So the following will make an input type with an optional field `name` with a 393 | default value `john`, an optional field `foo` with a default value `defaultFoo` and 394 | a mandatory field `bar` without any default value: 395 | 396 | ```php 397 | #[ORM\Column(type: 'string'] 398 | private $name = 'jane'; 399 | 400 | public function setName(string $name = 'john'): void 401 | { 402 | $this->name = $name; 403 | } 404 | 405 | public function setFoo(string $foo = 'defaultFoo'): void 406 | { 407 | // do something 408 | } 409 | 410 | public function setBar(string $bar): void 411 | { 412 | // do something 413 | } 414 | ``` 415 | 416 | ### Filtering and sorting 417 | 418 | It is possible to expose generic filtering for entity fields and their types to let users easily 419 | create and apply generic filters. This expose basic SQL-like syntax that should cover most simple 420 | cases. 421 | 422 | Filters are structured in an ordered list of groups. Each group contains an unordered set of joins 423 | and conditions on fields. For simple cases a single group of a few conditions would probably be enough. 424 | But the ordered list of group allow more advanced filtering with `OR` logic between a set of conditions. 425 | 426 | In the case of the `Post` class, it would generate [that GraphQL schema](tests/data/PostFilter.graphqls) 427 | for filtering, and for sorting it would be [that simpler schema](tests/data/PostSorting.graphqls). 428 | 429 | For concrete examples of possibilities and variables syntax, refer to the 430 | [test cases](tests/data/query-builder). 431 | 432 | For security and complexity reasons, it is not meant to solve advanced use cases. For those it is 433 | possible to write custom filters and sorting. 434 | 435 | #### Custom filters 436 | 437 | A custom filer must extend `AbstractOperator`. This will allow to define custom arguments for 438 | the API, and then a method to build the DQL condition corresponding to the argument. 439 | 440 | This would also allow to filter on joined relations by carefully adding joins when necessary. 441 | 442 | Then a custom filter might be used like so: 443 | 444 | ```php 445 | use Doctrine\ORM\Mapping as ORM; 446 | use GraphQL\Doctrine\Attribute as API; 447 | use GraphQLTests\Doctrine\Blog\Filtering\SearchOperatorType; 448 | 449 | /** 450 | * A blog post with title and body 451 | */ 452 | #[ORM\Entity] 453 | #[API\Filter(field: 'custom', operator: SearchOperatorType::class, type: 'string')] 454 | final class Post extends AbstractModel 455 | ``` 456 | 457 | #### Custom sorting 458 | 459 | A custom sorting option must implement `SortingInterface`. The constructor has no arguments and 460 | the `__invoke()` must define how to apply the sorting. 461 | 462 | Similarly to custom filters, it may be possible to carefully add joins if necessary. 463 | 464 | Then a custom sorting might be used like so: 465 | 466 | ```php 467 | use Doctrine\ORM\Mapping as ORM; 468 | use GraphQL\Doctrine\Attribute as API; 469 | use GraphQLTests\Doctrine\Blog\Sorting\UserName; 470 | 471 | /** 472 | * A blog post with title and body 473 | */ 474 | #[ORM\Entity] 475 | #[API\Sorting([UserName::class])] 476 | final class Post extends AbstractModel 477 | ``` 478 | 479 | ## Limitations 480 | 481 | ### Namespaces 482 | 483 | The `use` statement is not supported. So types in attributes or doc blocks must 484 | be the FQCN, or the name of a user-defined custom types (but never the short name of an entity). 485 | 486 | ### Composite identifiers 487 | 488 | Entities with composite identifiers are not supported for automatic creation of 489 | input types. Possible workarounds are to change input argument to be something 490 | else than an entity, write custom input types and use them via attributes, or 491 | adapt the database schema. 492 | 493 | ### Logical operators in filtering 494 | 495 | Logical operators support only two levels, and second level cannot mix logic operators. In SQL 496 | that would mean only one level of parentheses. So you can generate SQL that would look like: 497 | 498 | ```sql 499 | -- mixed top level 500 | WHERE cond1 AND cond2 OR cond3 AND ... 501 | 502 | -- mixed top level and non-mixed sublevels 503 | WHERE cond1 OR (cond2 OR cond3 OR ...) AND (cond4 AND cond5 AND ...) OR ... 504 | ``` 505 | 506 | But you **cannot** generate SQL that would like that: 507 | 508 | ```sql 509 | -- mixed sublevels does NOT work 510 | WHERE cond1 AND (cond2 OR cond3 AND cond4) AND ... 511 | 512 | -- more than two levels will NOT work 513 | WHERE cond1 OR (cond2 AND (cond3 OR cond4)) OR ... 514 | ``` 515 | 516 | Those cases would probably end up being too complex to handle on the client-side. And we recommend 517 | instead to implement them as a custom filter on the server side, in order to hide complexity 518 | from the client and benefit from Doctrine's QueryBuilder full flexibility. 519 | 520 | ### Sorting on join 521 | 522 | Out of the box, it is not possible to sort by a field from a joined relation. 523 | This should be done via a custom sorting to ensure that joins are done properly. 524 | 525 | ## Prior work 526 | 527 | [Doctrine GraphQL Mapper](https://github.com/rahuljayaraman/doctrine-graphql) has 528 | been an inspiration to write this package. While the goals are similar, the way 529 | it works is different. In Doctrine GraphQL Mapper, attributes are spread between 530 | properties and methods (and classes for filtering), but we work only on methods. 531 | Setup seems slightly more complex, but might be more flexible. We built on conventions 532 | and widespread use of PHP type hinting to have an easier out-of-the-box experience. 533 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ecodev/graphql-doctrine", 3 | "description": "Declare GraphQL types from Doctrine entities and attributes", 4 | "type": "library", 5 | "license": "MIT", 6 | "config": { 7 | "sort-packages": true, 8 | "allow-plugins": { 9 | "composer/package-versions-deprecated": true 10 | } 11 | }, 12 | "keywords": [ 13 | "api", 14 | "graphql", 15 | "doctrine", 16 | "doctrine-orm" 17 | ], 18 | "scripts": { 19 | "check": [ 20 | "php-cs-fixer fix --ansi --dry-run --diff", 21 | "phpunit --color=always", 22 | "phpstan analyse --ansi" 23 | ], 24 | "fix": [ 25 | "php-cs-fixer fix --ansi" 26 | ] 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "GraphQL\\Doctrine\\": "src" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "GraphQLTests\\Doctrine\\": "tests" 36 | } 37 | }, 38 | "require": { 39 | "php": "^8.2", 40 | "doctrine/orm": "^3.3", 41 | "psr/container": "^1.1 || ^2.0", 42 | "webonyx/graphql-php": "^15.20" 43 | }, 44 | "require-dev": { 45 | "friendsofphp/php-cs-fixer": "@stable", 46 | "laminas/laminas-servicemanager": "@stable", 47 | "phpstan/phpstan": "@stable", 48 | "phpunit/phpunit": "@stable", 49 | "symfony/cache": "@stable" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: '#^Parameter \#1 \$isNot of method GraphQL\\Doctrine\\Definition\\Operator\\AbstractSimpleOperator\:\:getDqlOperator\(\) expects bool, mixed given\.$#' 5 | identifier: argument.type 6 | count: 1 7 | path: src/Definition/Operator/AbstractSimpleOperator.php 8 | 9 | - 10 | message: '#^Parameter \#3 \$subject of function preg_replace expects array\\|string, string\|null given\.$#' 11 | identifier: argument.type 12 | count: 1 13 | path: src/DocBlockReader.php 14 | 15 | - 16 | message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' 17 | identifier: foreach.nonIterable 18 | count: 3 19 | path: src/Factory/FilteredQueryBuilderFactory.php 20 | 21 | - 22 | message: '#^Binary operation "\." between non\-falsy\-string and mixed results in an error\.$#' 23 | identifier: binaryOp.invalid 24 | count: 1 25 | path: src/Factory/FilteredQueryBuilderFactory.php 26 | 27 | - 28 | message: '#^Cannot access offset ''conditions'' on mixed\.$#' 29 | identifier: offsetAccess.nonOffsetAccessible 30 | count: 3 31 | path: src/Factory/FilteredQueryBuilderFactory.php 32 | 33 | - 34 | message: '#^Cannot access offset ''emptyStringAsHighest'' on mixed\.$#' 35 | identifier: offsetAccess.nonOffsetAccessible 36 | count: 1 37 | path: src/Factory/FilteredQueryBuilderFactory.php 38 | 39 | - 40 | message: '#^Cannot access offset ''field'' on mixed\.$#' 41 | identifier: offsetAccess.nonOffsetAccessible 42 | count: 2 43 | path: src/Factory/FilteredQueryBuilderFactory.php 44 | 45 | - 46 | message: '#^Cannot access offset ''joins'' on mixed\.$#' 47 | identifier: offsetAccess.nonOffsetAccessible 48 | count: 3 49 | path: src/Factory/FilteredQueryBuilderFactory.php 50 | 51 | - 52 | message: '#^Cannot access offset ''nullAsHighest'' on mixed\.$#' 53 | identifier: offsetAccess.nonOffsetAccessible 54 | count: 1 55 | path: src/Factory/FilteredQueryBuilderFactory.php 56 | 57 | - 58 | message: '#^Cannot access offset ''order'' on mixed\.$#' 59 | identifier: offsetAccess.nonOffsetAccessible 60 | count: 4 61 | path: src/Factory/FilteredQueryBuilderFactory.php 62 | 63 | - 64 | message: '#^Cannot access offset ''type'' on mixed\.$#' 65 | identifier: offsetAccess.nonOffsetAccessible 66 | count: 1 67 | path: src/Factory/FilteredQueryBuilderFactory.php 68 | 69 | - 70 | message: '#^Parameter \#1 \$group of method GraphQL\\Doctrine\\Factory\\FilteredQueryBuilderFactory\:\:applyCollectedDqlConditions\(\) expects array, mixed given\.$#' 71 | identifier: argument.type 72 | count: 1 73 | path: src/Factory/FilteredQueryBuilderFactory.php 74 | 75 | - 76 | message: '#^Parameter \#1 \$name of method GraphQL\\Type\\Definition\\InputObjectType\:\:getField\(\) expects string, mixed given\.$#' 77 | identifier: argument.type 78 | count: 2 79 | path: src/Factory/FilteredQueryBuilderFactory.php 80 | 81 | - 82 | message: '#^Parameter \#2 \$name of method GraphQL\\Doctrine\\Factory\\Type\\SortingTypeFactory\:\:getCustomSorting\(\) expects string, mixed given\.$#' 83 | identifier: argument.type 84 | count: 1 85 | path: src/Factory/FilteredQueryBuilderFactory.php 86 | 87 | - 88 | message: '#^Parameter \#2 \$order of method Doctrine\\ORM\\QueryBuilder\:\:addOrderBy\(\) expects string\|null, mixed given\.$#' 89 | identifier: argument.type 90 | count: 3 91 | path: src/Factory/FilteredQueryBuilderFactory.php 92 | 93 | - 94 | message: '#^Parameter \#3 \$joinType of method GraphQL\\Doctrine\\Factory\\FilteredQueryBuilderFactory\:\:createJoin\(\) expects string, mixed given\.$#' 95 | identifier: argument.type 96 | count: 1 97 | path: src/Factory/FilteredQueryBuilderFactory.php 98 | 99 | - 100 | message: '#^Parameter \#4 \$joins of method GraphQL\\Doctrine\\Factory\\FilteredQueryBuilderFactory\:\:applyJoinsAndFilters\(\) expects array, mixed given\.$#' 101 | identifier: argument.type 102 | count: 2 103 | path: src/Factory/FilteredQueryBuilderFactory.php 104 | 105 | - 106 | message: '#^Parameter \#5 \$conditions of method GraphQL\\Doctrine\\Factory\\FilteredQueryBuilderFactory\:\:applyJoinsAndFilters\(\) expects array, mixed given\.$#' 107 | identifier: argument.type 108 | count: 2 109 | path: src/Factory/FilteredQueryBuilderFactory.php 110 | 111 | - 112 | message: '#^Parameter \#5 \$field of method GraphQL\\Doctrine\\Definition\\Operator\\AbstractOperator\:\:getDqlCondition\(\) expects string, mixed given\.$#' 113 | identifier: argument.type 114 | count: 1 115 | path: src/Factory/FilteredQueryBuilderFactory.php 116 | 117 | - 118 | message: '#^Parameter \#5 \$order of callable GraphQL\\Doctrine\\Sorting\\SortingInterface expects string, mixed given\.$#' 119 | identifier: argument.type 120 | count: 1 121 | path: src/Factory/FilteredQueryBuilderFactory.php 122 | 123 | - 124 | message: '#^Parameter \#6 \$args of method GraphQL\\Doctrine\\Definition\\Operator\\AbstractOperator\:\:getDqlCondition\(\) expects array\|null, mixed given\.$#' 125 | identifier: argument.type 126 | count: 1 127 | path: src/Factory/FilteredQueryBuilderFactory.php 128 | 129 | - 130 | message: '#^Parameter \#3 \$subject of function preg_replace expects array\\|string, string\|null given\.$#' 131 | identifier: argument.type 132 | count: 1 133 | path: src/Factory/Type/AbstractTypeFactory.php 134 | 135 | - 136 | message: '#^PHPDoc tag @var with type GraphQL\\Type\\Definition\\LeafType is not subtype of native type GraphQL\\Type\\Definition\\NamedType&GraphQL\\Type\\Definition\\Type\.$#' 137 | identifier: varTag.nativeType 138 | count: 3 139 | path: src/Factory/Type/FilterGroupConditionTypeFactory.php 140 | 141 | - 142 | message: '#^Parameter \#1 \$property of method GraphQL\\Doctrine\\Factory\\AbstractFactory\:\:isPropertyExcluded\(\) expects ReflectionProperty, ReflectionProperty\|null given\.$#' 143 | identifier: argument.type 144 | count: 1 145 | path: src/Factory/Type/FilterGroupConditionTypeFactory.php 146 | 147 | - 148 | message: '#^Parameter \#1 \$property of method GraphQL\\Doctrine\\Factory\\Type\\FilterGroupConditionTypeFactory\:\:getLeafType\(\) expects ReflectionProperty, ReflectionProperty\|null given\.$#' 149 | identifier: argument.type 150 | count: 1 151 | path: src/Factory/Type/FilterGroupConditionTypeFactory.php 152 | 153 | - 154 | message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' 155 | identifier: foreach.nonIterable 156 | count: 1 157 | path: src/Factory/Type/PartialInputTypeFactory.php 158 | 159 | - 160 | message: '#^Cannot access offset ''defaultValue'' on mixed\.$#' 161 | identifier: offsetAccess.nonOffsetAccessible 162 | count: 1 163 | path: src/Factory/Type/PartialInputTypeFactory.php 164 | 165 | - 166 | message: '#^Cannot access offset ''type'' on mixed\.$#' 167 | identifier: offsetAccess.nonOffsetAccessible 168 | count: 1 169 | path: src/Factory/Type/PartialInputTypeFactory.php 170 | 171 | - 172 | message: '#^Property GraphQL\\Type\\Definition\\InputObjectType\:\:\$config \(array\{name\?\: string\|null, description\?\: string\|null, fields\: \(callable\(\)\: iterable\\)\|iterable\, parseValue\?\: callable\(array\\)\: mixed, astNode\?\: GraphQL\\Language\\AST\\InputObjectTypeDefinitionNode\|null, extensionASTNodes\?\: array\\|null\}\) does not accept array\{name\?\: string\|null, description\?\: string\|null, fields\: Closure\(\)\: list, parseValue\?\: callable\(array\\)\: mixed, astNode\?\: GraphQL\\Language\\AST\\InputObjectTypeDefinitionNode\|null, extensionASTNodes\?\: array\\|null\}\.$#' 173 | identifier: assign.propertyType 174 | count: 1 175 | path: src/Factory/Type/PartialInputTypeFactory.php 176 | 177 | - 178 | message: '#^Parameter \#1 \$property of method GraphQL\\Doctrine\\Factory\\AbstractFactory\:\:isPropertyExcluded\(\) expects ReflectionProperty, ReflectionProperty\|null given\.$#' 179 | identifier: argument.type 180 | count: 1 181 | path: src/Factory/Type/SortingTypeFactory.php 182 | 183 | - 184 | message: '#^Match arm comparison between '''' and '''' is always true\.$#' 185 | identifier: match.alwaysTrue 186 | count: 1 187 | path: src/Types.php 188 | 189 | - 190 | message: '#^Method GraphQL\\Doctrine\\Types\:\:getOperator\(\) should return GraphQL\\Doctrine\\Definition\\Operator\\AbstractOperator but returns GraphQL\\Type\\Definition\\NamedType&GraphQL\\Type\\Definition\\Type\.$#' 191 | identifier: return.type 192 | count: 1 193 | path: src/Types.php 194 | 195 | - 196 | message: '#^Binary operation "\." between mixed and '' LIKE \:'' results in an error\.$#' 197 | identifier: binaryOp.invalid 198 | count: 1 199 | path: tests/Blog/Filtering/SearchOperatorType.php 200 | 201 | - 202 | message: '#^Cannot cast mixed to string\.$#' 203 | identifier: cast.string 204 | count: 1 205 | path: tests/Blog/Filtering/SearchOperatorType.php 206 | 207 | - 208 | message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true and ''passed validation…'' will always evaluate to true\.$#' 209 | identifier: staticMethod.alreadyNarrowedType 210 | count: 1 211 | path: tests/TypesTest.php 212 | 213 | - 214 | message: '#^Cannot access offset ''filter'' on mixed\.$#' 215 | identifier: offsetAccess.nonOffsetAccessible 216 | count: 1 217 | path: tests/TypesTest.php 218 | 219 | - 220 | message: '#^Cannot access offset ''sorting'' on mixed\.$#' 221 | identifier: offsetAccess.nonOffsetAccessible 222 | count: 1 223 | path: tests/TypesTest.php 224 | 225 | - 226 | message: '#^Parameter \#2 \$filter of method GraphQL\\Doctrine\\Types\:\:createFilteredQueryBuilder\(\) expects array, mixed given\.$#' 227 | identifier: argument.type 228 | count: 1 229 | path: tests/TypesTest.php 230 | 231 | - 232 | message: '#^Parameter \#3 \$sorting of method GraphQL\\Doctrine\\Types\:\:createFilteredQueryBuilder\(\) expects array, mixed given\.$#' 233 | identifier: argument.type 234 | count: 1 235 | path: tests/TypesTest.php 236 | -------------------------------------------------------------------------------- /src/Attribute/AbstractAttribute.php: -------------------------------------------------------------------------------- 1 | setDefaultValue($defaultValue); 36 | } 37 | } 38 | 39 | public function toArray(): array 40 | { 41 | $data = [ 42 | 'name' => $this->getName(), 43 | 'type' => $this->getTypeInstance(), 44 | 'description' => $this->getDescription(), 45 | ]; 46 | 47 | if ($this->hasDefaultValue()) { 48 | $data['defaultValue'] = $this->getDefaultValue(); 49 | } 50 | 51 | return $data; 52 | } 53 | 54 | public function getName(): ?string 55 | { 56 | return $this->name; 57 | } 58 | 59 | public function setName(string $name): void 60 | { 61 | $this->name = $name; 62 | } 63 | 64 | public function getType(): ?string 65 | { 66 | return $this->type; 67 | } 68 | 69 | public function getDescription(): ?string 70 | { 71 | return $this->description; 72 | } 73 | 74 | public function setDescription(?string $description): void 75 | { 76 | $this->description = $description; 77 | } 78 | 79 | public function hasDefaultValue(): bool 80 | { 81 | return $this->hasDefaultValue; 82 | } 83 | 84 | public function getDefaultValue(): mixed 85 | { 86 | return $this->defaultValue; 87 | } 88 | 89 | public function setDefaultValue(mixed $defaultValue): void 90 | { 91 | $this->defaultValue = $defaultValue; 92 | $this->hasDefaultValue = true; 93 | } 94 | 95 | public function getTypeInstance(): ?Type 96 | { 97 | return $this->typeInstance; 98 | } 99 | 100 | public function setTypeInstance(?Type $typeInstance): void 101 | { 102 | $this->typeInstance = $typeInstance; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Attribute/ApiAttribute.php: -------------------------------------------------------------------------------- 1 | type = $type; 36 | } 37 | 38 | public function toArray(): array 39 | { 40 | $args = []; 41 | foreach ($this->args as $arg) { 42 | $args[] = $arg->toArray(); 43 | } 44 | 45 | return [ 46 | 'name' => $this->name, 47 | 'type' => $this->type, 48 | 'description' => $this->description, 49 | 'args' => $args, 50 | ]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Attribute/Filter.php: -------------------------------------------------------------------------------- 1 | $operator FQCN to the PHP class implementing the GraphQL operator 22 | * @param string $type GraphQL leaf type name of the type of the field 23 | */ 24 | public function __construct( 25 | public readonly string $field, 26 | public readonly string $operator, 27 | public readonly string $type, 28 | ) { 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Attribute/FilterGroupCondition.php: -------------------------------------------------------------------------------- 1 | $attributeName 25 | * 26 | * @return array attributes indexed by the class name where they were found 27 | */ 28 | public function getRecursiveClassAttributes(ReflectionClass $class, string $attributeName): array 29 | { 30 | $result = []; 31 | 32 | $attributes = $this->getAttributeInstances($class, $attributeName); 33 | if ($attributes) { 34 | $result[$class->getName()] = $attributes; 35 | } 36 | 37 | foreach ($class->getTraits() as $trait) { 38 | $result = array_merge($result, self::getRecursiveClassAttributes($trait, $attributeName)); 39 | } 40 | 41 | $parent = $class->getParentClass(); 42 | if ($parent) { 43 | $result = array_merge($result, self::getRecursiveClassAttributes($parent, $attributeName)); 44 | } 45 | 46 | return $result; 47 | } 48 | 49 | /** 50 | * @template T of ApiAttribute 51 | * 52 | * @param class-string $attributeName 53 | * 54 | * @return null|T 55 | */ 56 | public function getAttribute(ReflectionClass|ReflectionProperty|ReflectionMethod|ReflectionParameter $element, string $attributeName): ?ApiAttribute 57 | { 58 | $attributes = $this->getAttributeInstances($element, $attributeName); 59 | 60 | return reset($attributes) ?: null; 61 | } 62 | 63 | /** 64 | * @template T of ApiAttribute 65 | * 66 | * @param class-string $attributeName 67 | * 68 | * @return T[] 69 | */ 70 | private function getAttributeInstances(ReflectionClass|ReflectionMethod|ReflectionParameter|ReflectionProperty $element, string $attributeName): array 71 | { 72 | if (!is_subclass_of($attributeName, ApiAttribute::class)) { 73 | throw new Exception(self::class . ' cannot be used for attribute than are not part of `ecodev/graphql-doctrine`.'); 74 | } 75 | 76 | $attributes = $element->getAttributes($attributeName); 77 | $instances = []; 78 | 79 | foreach ($attributes as $attribute) { 80 | $instance = $attribute->newInstance(); 81 | $instances[] = $instance; 82 | } 83 | 84 | return $instances; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Attribute/Sorting.php: -------------------------------------------------------------------------------- 1 | $class FQCN of PHP class implementing `SortingInterface` 18 | */ 19 | public function __construct(public readonly string $class) 20 | { 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/DefaultFieldResolver.php: -------------------------------------------------------------------------------- 1 | fieldName; 25 | $property = null; 26 | 27 | if (is_object($source)) { 28 | $property = $this->resolveObject($source, $args, $fieldName); 29 | } elseif (is_array($source)) { 30 | $property = $this->resolveArray($source, $fieldName); 31 | } 32 | 33 | return $property instanceof Closure ? $property($source, $args, $context) : $property; 34 | } 35 | 36 | /** 37 | * Resolve for an object. 38 | */ 39 | private function resolveObject(object $source, array $args, string $fieldName): mixed 40 | { 41 | $getter = $this->getGetter($source, $fieldName); 42 | if ($getter) { 43 | $args = $this->orderArguments($getter, $args); 44 | 45 | return $getter->invoke($source, ...$args); 46 | } 47 | 48 | if (isset($source->{$fieldName})) { 49 | return $source->{$fieldName}; 50 | } 51 | 52 | return null; 53 | } 54 | 55 | /** 56 | * Resolve for an array. 57 | */ 58 | private function resolveArray(array $source, string $fieldName): mixed 59 | { 60 | return $source[$fieldName] ?? null; 61 | } 62 | 63 | /** 64 | * Return the getter/isser method if any valid one exists. 65 | */ 66 | private function getGetter(object $source, string $name): ?ReflectionMethod 67 | { 68 | if (!preg_match('~^(is|has)[A-Z]~', $name)) { 69 | $name = 'get' . ucfirst($name); 70 | } 71 | 72 | $class = new ReflectionClass($source); 73 | if ($class->hasMethod($name)) { 74 | $method = $class->getMethod($name); 75 | if ($method->getModifiers() & ReflectionMethod::IS_PUBLIC) { 76 | return $method; 77 | } 78 | } 79 | 80 | return null; 81 | } 82 | 83 | /** 84 | * Re-order associative args to ordered args. 85 | */ 86 | private function orderArguments(ReflectionMethod $method, array $args): array 87 | { 88 | $result = []; 89 | if (!$args) { 90 | return $result; 91 | } 92 | 93 | foreach ($method->getParameters() as $param) { 94 | if (array_key_exists($param->getName(), $args)) { 95 | $arg = $args[$param->getName()]; 96 | 97 | // Fetch entity from DB 98 | if ($arg instanceof EntityID) { 99 | $arg = $arg->getEntity(); 100 | } 101 | 102 | $result[] = $arg; 103 | } 104 | } 105 | 106 | return $result; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Definition/EntityID.php: -------------------------------------------------------------------------------- 1 | $className the entity class name 19 | * @param null|string $id the entity id 20 | */ 21 | public function __construct( 22 | private readonly EntityManager $entityManager, 23 | private readonly string $className, 24 | private readonly ?string $id 25 | ) { 26 | } 27 | 28 | /** 29 | * Get the ID. 30 | */ 31 | public function getId(): ?string 32 | { 33 | return $this->id; 34 | } 35 | 36 | /** 37 | * Get the entity from DB. 38 | * 39 | * @return T entity 40 | */ 41 | public function getEntity(): object 42 | { 43 | $entity = $this->entityManager->getRepository($this->className)->find($this->id); 44 | if (!$entity) { 45 | throw new UserError('Entity not found for class `' . $this->className . '` and ID `' . $this->id . '`.'); 46 | } 47 | 48 | return $entity; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Definition/EntityIDType.php: -------------------------------------------------------------------------------- 1 | name = $typeName; 29 | $this->description = 'Automatically generated type to be used as input where an object of type `' . Utils::getTypeName($className) . '` is needed'; 30 | 31 | parent::__construct(); 32 | } 33 | 34 | /** 35 | * Serializes an internal value to include in a response. 36 | * 37 | * @param object $value 38 | */ 39 | public function serialize($value): string 40 | { 41 | $id = $this->entityManager->getClassMetadata($this->className)->getIdentifierValues($value); 42 | 43 | // @phpstan-ignore-next-line 44 | return (string) reset($id); 45 | } 46 | 47 | /** 48 | * Parses an externally provided value (query variable) to use as an input. 49 | */ 50 | public function parseValue(mixed $value): EntityID 51 | { 52 | if (!is_string($value) && !is_int($value)) { 53 | throw new Error('EntityID cannot represent value: ' . \GraphQL\Utils\Utils::printSafe($value)); 54 | } 55 | 56 | return $this->createEntityID((string) $value); 57 | } 58 | 59 | /** 60 | * Parses an externally provided literal value (hardcoded in GraphQL query) to use as an input. 61 | */ 62 | public function parseLiteral(Node $valueNode, ?array $variables = null): EntityID 63 | { 64 | if ($valueNode instanceof StringValueNode || $valueNode instanceof IntValueNode) { 65 | return $this->createEntityID((string) $valueNode->value); 66 | } 67 | 68 | // Intentionally without message, as all information already in wrapped Exception 69 | throw new Error(); 70 | } 71 | 72 | /** 73 | * Create EntityID to retrieve entity from DB later on. 74 | */ 75 | private function createEntityID(string $id): EntityID 76 | { 77 | return new EntityID($this->entityManager, $this->className, $id); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Definition/JoinTypeType.php: -------------------------------------------------------------------------------- 1 | 'Join types to be used in DQL', 18 | 'values' => [ 19 | 'innerJoin', 20 | 'leftJoin', 21 | ], 22 | ]; 23 | 24 | parent::__construct($config); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Definition/LogicalOperatorType.php: -------------------------------------------------------------------------------- 1 | 'Logical operator to be used in conditions', 18 | 'values' => [ 19 | 'AND', 20 | 'OR', 21 | ], 22 | ]; 23 | 24 | parent::__construct($config); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Definition/Operator/AbstractAssociationOperatorType.php: -------------------------------------------------------------------------------- 1 | isSingleValuedAssociation($field)) { 20 | return $this->getSingleValuedDqlCondition($uniqueNameFactory, $metadata, $queryBuilder, $alias, $field, $args); 21 | } 22 | 23 | return $this->getCollectionValuedDqlCondition($uniqueNameFactory, $metadata, $queryBuilder, $alias, $field, $args); 24 | } 25 | 26 | abstract protected function getSingleValuedDqlCondition(UniqueNameFactory $uniqueNameFactory, ClassMetadata $metadata, QueryBuilder $queryBuilder, string $alias, string $field, array $args): string; 27 | 28 | abstract protected function getCollectionValuedDqlCondition(UniqueNameFactory $uniqueNameFactory, ClassMetadata $metadata, QueryBuilder $queryBuilder, string $alias, string $field, array $args): string; 29 | } 30 | -------------------------------------------------------------------------------- /src/Definition/Operator/AbstractOperator.php: -------------------------------------------------------------------------------- 1 | getConfiguration($leafType); 27 | 28 | // Override type name to be predictable 29 | $config['name'] = Utils::getOperatorTypeName(static::class, $leafType); 30 | 31 | parent::__construct($config); 32 | } 33 | 34 | /** 35 | * Return the GraphQL type configuration for an `InputObjectType`. 36 | * 37 | * This should declare all custom fields needed to apply the filter. In most 38 | * cases it would include a field such as `value` or `values`, and possibly other 39 | * more specific fields. 40 | * 41 | * The type name, usually configured with the `name` key, should not be defined and 42 | * will be overridden in all cases. This is because we must have a predictable name 43 | * that is based only on the class name. 44 | */ 45 | abstract protected function getConfiguration(LeafType $leafType): array; 46 | 47 | /** 48 | * Return the DQL condition to apply the filter. 49 | * 50 | * In most cases a DQL condition should be returned as a string, but it might be useful to 51 | * return null if the filter is not applicable (eg: a search term with empty string). 52 | * 53 | * The query builder: 54 | * 55 | * - MUST NOT be used to apply the condition directly (with `*where()` methods). Instead the condition MUST 56 | * be returned as string. Otherwise it will break OR/AND logic of sibling operators. 57 | * - MAY be used to inspect existing joins and add joins if needed. 58 | * - SHOULD be used to set query parameter (with the helper of `UniqueNameFactory`) 59 | * 60 | * @param UniqueNameFactory $uniqueNameFactory a helper to get unique names to be used in the query 61 | * @param string $alias the alias for the entity on which to apply the filter 62 | * @param string $field the field for the entity on which to apply the filter 63 | * @param null|array $args all arguments specific to this operator as declared in its configuration 64 | */ 65 | abstract public function getDqlCondition(UniqueNameFactory $uniqueNameFactory, ClassMetadata $metadata, QueryBuilder $queryBuilder, string $alias, string $field, ?array $args): string; 66 | } 67 | -------------------------------------------------------------------------------- /src/Definition/Operator/AbstractSimpleOperator.php: -------------------------------------------------------------------------------- 1 | [ 23 | [ 24 | 'name' => 'value', 25 | 'type' => self::nonNull($leafType), 26 | ], 27 | [ 28 | 'name' => 'not', 29 | 'type' => self::boolean(), 30 | 'defaultValue' => false, 31 | ], 32 | ], 33 | ]; 34 | } 35 | 36 | final public function getDqlCondition(UniqueNameFactory $uniqueNameFactory, ClassMetadata $metadata, QueryBuilder $queryBuilder, string $alias, string $field, ?array $args): string 37 | { 38 | if ($args === null) { 39 | return ''; 40 | } 41 | 42 | $param = $uniqueNameFactory->createParameterName(); 43 | $queryBuilder->setParameter($param, $args['value']); 44 | 45 | return $alias . '.' . $field . ' ' . $this->getDqlOperator($args['not']) . ' :' . $param; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Definition/Operator/BetweenOperatorType.php: -------------------------------------------------------------------------------- 1 | [ 18 | [ 19 | 'name' => 'from', 20 | 'type' => self::nonNull($leafType), 21 | ], 22 | [ 23 | 'name' => 'to', 24 | 'type' => self::nonNull($leafType), 25 | ], 26 | [ 27 | 'name' => 'not', 28 | 'type' => self::boolean(), 29 | 'defaultValue' => false, 30 | ], 31 | ], 32 | ]; 33 | } 34 | 35 | public function getDqlCondition(UniqueNameFactory $uniqueNameFactory, ClassMetadata $metadata, QueryBuilder $queryBuilder, string $alias, string $field, ?array $args): string 36 | { 37 | if ($args === null) { 38 | return ''; 39 | } 40 | 41 | $from = $uniqueNameFactory->createParameterName(); 42 | $to = $uniqueNameFactory->createParameterName(); 43 | $queryBuilder->setParameter($from, $args['from']); 44 | $queryBuilder->setParameter($to, $args['to']); 45 | $not = $args['not'] ? 'NOT ' : ''; 46 | 47 | return $alias . '.' . $field . ' ' . $not . 'BETWEEN :' . $from . ' AND ' . ':' . $to; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Definition/Operator/EmptyOperatorType.php: -------------------------------------------------------------------------------- 1 | 'When used on single valued association, it will use `IS NULL` operator. On collection valued association it will use `IS EMPTY` operator.', 18 | 'fields' => [ 19 | [ 20 | 'name' => 'not', 21 | 'type' => self::boolean(), 22 | 'defaultValue' => false, 23 | ], 24 | ], 25 | ]; 26 | } 27 | 28 | protected function getSingleValuedDqlCondition(UniqueNameFactory $uniqueNameFactory, ClassMetadata $metadata, QueryBuilder $queryBuilder, string $alias, string $field, array $args): string 29 | { 30 | $null = $this->types->getOperator(NullOperatorType::class, self::id()); 31 | 32 | return $null->getDqlCondition($uniqueNameFactory, $metadata, $queryBuilder, $alias, $field, $args); 33 | } 34 | 35 | protected function getCollectionValuedDqlCondition(UniqueNameFactory $uniqueNameFactory, ClassMetadata $metadata, QueryBuilder $queryBuilder, string $alias, string $field, array $args): string 36 | { 37 | $not = $args['not'] ? 'NOT ' : ''; 38 | 39 | return $alias . '.' . $field . ' IS ' . $not . 'EMPTY'; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Definition/Operator/EqualOperatorType.php: -------------------------------------------------------------------------------- 1 | '; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Definition/Operator/GreaterOrEqualOperatorType.php: -------------------------------------------------------------------------------- 1 | ='; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Definition/Operator/GroupOperatorType.php: -------------------------------------------------------------------------------- 1 | $description, 24 | 'fields' => [ 25 | [ 26 | 'name' => 'value', 27 | 'type' => self::boolean(), 28 | 'defaultValue' => null, 29 | 'description' => 'This field is never used and can be ignored', 30 | ], 31 | ], 32 | ]; 33 | } 34 | 35 | public function getDqlCondition(UniqueNameFactory $uniqueNameFactory, ClassMetadata $metadata, QueryBuilder $queryBuilder, string $alias, string $field, ?array $args): string 36 | { 37 | $queryBuilder->addGroupBy($alias . '.' . $field); 38 | 39 | return ''; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Definition/Operator/HaveOperatorType.php: -------------------------------------------------------------------------------- 1 | 'When used on single valued association, it will use `IN` operator. On collection valued association it will use `MEMBER OF` operator.', 19 | 'fields' => [ 20 | [ 21 | 'name' => 'values', 22 | 'type' => self::nonNull(self::listOf(self::nonNull(self::id()))), 23 | ], 24 | [ 25 | 'name' => 'not', 26 | 'type' => self::boolean(), 27 | 'defaultValue' => false, 28 | ], 29 | ], 30 | ]; 31 | } 32 | 33 | protected function getSingleValuedDqlCondition(UniqueNameFactory $uniqueNameFactory, ClassMetadata $metadata, QueryBuilder $queryBuilder, string $alias, string $field, array $args): string 34 | { 35 | $in = $this->types->getOperator(InOperatorType::class, self::id()); 36 | 37 | return $in->getDqlCondition($uniqueNameFactory, $metadata, $queryBuilder, $alias, $field, $args); 38 | } 39 | 40 | protected function getCollectionValuedDqlCondition(UniqueNameFactory $uniqueNameFactory, ClassMetadata $metadata, QueryBuilder $queryBuilder, string $alias, string $field, array $args): string 41 | { 42 | $association = $metadata->associationMappings[$field]; 43 | $values = $uniqueNameFactory->createParameterName(); 44 | $queryBuilder->setParameter($values, $args['values']); 45 | $not = $args['not'] ? 'NOT ' : ''; 46 | 47 | // For one-to-many we cannot rely on MEMBER OF, because it does not support multiple values (in SQL it always 48 | // use `=`, and not `IN()`). So we simulate an approximation of MEMBER OF that support multiple values. But it 49 | // does **not** support composite identifiers. And that is fine because it is an official limitation of this 50 | // library anyway. 51 | if ($association instanceof OneToManyAssociationMapping) { 52 | $id = $metadata->identifier[0]; 53 | 54 | $otherClassName = $association->targetEntity; 55 | $otherAlias = $uniqueNameFactory->createAliasName($otherClassName); 56 | $otherField = $association->mappedBy; 57 | $otherMetadata = $queryBuilder->getEntityManager()->getClassMetadata($otherClassName); 58 | $otherId = $otherMetadata->identifier[0]; 59 | 60 | $result = $not . "EXISTS (SELECT 1 FROM $otherClassName $otherAlias WHERE $otherAlias.$otherField = $alias.$id AND $otherAlias.$otherId IN (:$values))"; 61 | 62 | return $result; 63 | } 64 | 65 | return ':' . $values . ' ' . $not . 'MEMBER OF ' . $alias . '.' . $field; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Definition/Operator/InOperatorType.php: -------------------------------------------------------------------------------- 1 | [ 18 | [ 19 | 'name' => 'values', 20 | 'type' => self::nonNull(self::listOf(self::nonNull($leafType))), 21 | ], 22 | [ 23 | 'name' => 'not', 24 | 'type' => self::boolean(), 25 | 'defaultValue' => false, 26 | ], 27 | ], 28 | ]; 29 | } 30 | 31 | public function getDqlCondition(UniqueNameFactory $uniqueNameFactory, ClassMetadata $metadata, QueryBuilder $queryBuilder, string $alias, string $field, ?array $args): string 32 | { 33 | if ($args === null) { 34 | return ''; 35 | } 36 | 37 | $values = $uniqueNameFactory->createParameterName(); 38 | $queryBuilder->setParameter($values, $args['values']); 39 | $not = $args['not'] ? 'NOT ' : ''; 40 | 41 | return $alias . '.' . $field . ' ' . $not . 'IN (:' . $values . ')'; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Definition/Operator/LessOperatorType.php: -------------------------------------------------------------------------------- 1 | =' : '<'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Definition/Operator/LessOrEqualOperatorType.php: -------------------------------------------------------------------------------- 1 | ' : '<='; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Definition/Operator/LikeOperatorType.php: -------------------------------------------------------------------------------- 1 | [ 18 | [ 19 | 'name' => 'not', 20 | 'type' => self::boolean(), 21 | 'defaultValue' => false, 22 | ], 23 | ], 24 | ]; 25 | } 26 | 27 | public function getDqlCondition(UniqueNameFactory $uniqueNameFactory, ClassMetadata $metadata, QueryBuilder $queryBuilder, string $alias, string $field, ?array $args): string 28 | { 29 | if ($args === null) { 30 | return ''; 31 | } 32 | 33 | $not = $args['not'] ? 'NOT ' : ''; 34 | 35 | return $alias . '.' . $field . ' IS ' . $not . 'NULL'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Definition/SortingOrderType.php: -------------------------------------------------------------------------------- 1 | 'Order to be used in DQL', 18 | 'values' => [ 19 | 'ASC', 20 | 'DESC', 21 | ], 22 | ]; 23 | 24 | parent::__construct($config); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/DocBlockReader.php: -------------------------------------------------------------------------------- 1 | comment = $method->getDocComment() ?: ''; 20 | } 21 | 22 | /** 23 | * Get the description of a method from the doc block. 24 | */ 25 | public function getMethodDescription(): ?string 26 | { 27 | // Remove the comment markers 28 | $description = preg_replace('~\*/$~', '', $this->comment); 29 | $description = preg_replace('~^\s*(/\*\*|\* ?|\*/)~m', '', $description); 30 | 31 | // Keep everything before the first annotation 32 | $description = trim(explode('@', $description ?? '')[0]); 33 | 34 | // Drop common "Get" or "Return" in front of comment 35 | $description = ucfirst(preg_replace('~^(set|get|return)s? ~i', '', $description) ?? ''); 36 | 37 | return $description ?: null; 38 | } 39 | 40 | /** 41 | * Get the parameter description. 42 | */ 43 | public function getParameterDescription(ReflectionParameter $param): ?string 44 | { 45 | $name = preg_quote($param->getName()); 46 | 47 | if (preg_match('~@param\h+\H+\h+\$' . $name . '\h+(.*)~', $this->comment, $m)) { 48 | return ucfirst(trim($m[1])); 49 | } 50 | 51 | return null; 52 | } 53 | 54 | /** 55 | * Get the parameter type. 56 | */ 57 | public function getParameterType(ReflectionParameter $param): ?string 58 | { 59 | $name = preg_quote($param->getName()); 60 | 61 | if (preg_match('~@param\h+(\H+)\h+\$' . $name . '(\h|\n)~', $this->comment, $m)) { 62 | return trim($m[1]); 63 | } 64 | 65 | return null; 66 | } 67 | 68 | /** 69 | * Get the return type. 70 | */ 71 | public function getReturnType(): ?string 72 | { 73 | if (preg_match('~@return\h+([^<]+<.*>|\H+)(\h|\n)~', $this->comment, $m)) { 74 | return trim($m[1]); 75 | } 76 | 77 | return null; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | reader = new Reader(); 26 | } 27 | 28 | /** 29 | * Returns whether the property is excluded. 30 | */ 31 | final protected function isPropertyExcluded(ReflectionProperty $property): bool 32 | { 33 | $exclude = $this->reader->getAttribute($property, Exclude::class); 34 | 35 | return $exclude !== null; 36 | } 37 | 38 | /** 39 | * Get instance of GraphQL type from a PHP class name. 40 | * 41 | * Supported syntaxes are the following: 42 | * 43 | * - `?MyType` 44 | * - `null|MyType` 45 | * - `MyType|null` 46 | * - `MyType[]` 47 | * - `?MyType[]` 48 | * - `null|MyType[]` 49 | * - `MyType[]|null` 50 | * - `Collection` 51 | */ 52 | final protected function getTypeFromPhpDeclaration(ReflectionClass $class, null|string|Type $typeDeclaration, bool $isEntityId = false): null|Type 53 | { 54 | if ($typeDeclaration === null || $typeDeclaration instanceof Type) { 55 | return $typeDeclaration; 56 | } 57 | 58 | $isNullable = 0; 59 | $name = preg_replace('~(^\?|^null\||\|null$)~', '', $typeDeclaration, count: $isNullable); 60 | 61 | $isList = 0; 62 | $name = preg_replace_callback( 63 | '~^([^<]*)\[]$|^Collection<(.*),(.*)>$~', 64 | fn (array $m) => $m[1] . trim($m[3] ?? ''), 65 | $name ?? '', 66 | count: $isList, 67 | ); 68 | $name = $this->adjustNamespace($class, $name); 69 | $type = $this->getTypeFromRegistry($name, $isEntityId); 70 | 71 | if ($isList) { 72 | $type = Type::listOf(Type::nonNull($type)); 73 | } 74 | 75 | if (!$isNullable) { 76 | $type = Type::nonNull($type); 77 | } 78 | 79 | // @phpstan-ignore-next-line 80 | return $type; 81 | } 82 | 83 | /** 84 | * Prepend namespace of the method if the class actually exists. 85 | */ 86 | private function adjustNamespace(ReflectionClass $class, string $type): string 87 | { 88 | if ($type === 'self') { 89 | $type = $class->getName(); 90 | } 91 | 92 | $namespace = $class->getNamespaceName(); 93 | if ($namespace) { 94 | $namespacedType = $namespace . '\\' . $type; 95 | 96 | if (class_exists($namespacedType)) { 97 | return $namespacedType; 98 | } 99 | } 100 | 101 | return $type; 102 | } 103 | 104 | /** 105 | * Returns a type from our registry. 106 | */ 107 | final protected function getTypeFromRegistry(string $type, bool $isEntityId): NamedType 108 | { 109 | if ($type === 'ID') { 110 | return Type::id(); 111 | } 112 | 113 | if ($this->types->isEntity($type) && $isEntityId) { 114 | // @phpstan-ignore-next-line 115 | return $this->types->getId($type); 116 | } 117 | 118 | if ($this->types->isEntity($type) && !$isEntityId) { 119 | // @phpstan-ignore-next-line 120 | return $this->types->getOutput($type); 121 | } 122 | 123 | return $this->types->get($type); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Factory/AbstractFieldsConfigurationFactory.php: -------------------------------------------------------------------------------- 1 | findIdentityField($className); 54 | 55 | $class = $this->metadata->getReflectionClass(); 56 | $methods = $class->getMethods(ReflectionMethod::IS_PUBLIC); 57 | $fieldConfigurations = []; 58 | foreach ($methods as $method) { 59 | // Skip non-callable or non-instance 60 | if ($method->isAbstract() || $method->isStatic()) { 61 | continue; 62 | } 63 | 64 | // Skip non-getter methods 65 | $name = $method->getName(); 66 | if (!preg_match($this->getMethodPattern(), $name)) { 67 | continue; 68 | } 69 | 70 | // Skip exclusion specified by user 71 | if ($this->isExcluded($method)) { 72 | continue; 73 | } 74 | 75 | $configuration = $this->methodToConfiguration($method); 76 | if ($configuration) { 77 | $fieldConfigurations[] = $configuration; 78 | } 79 | } 80 | 81 | return $fieldConfigurations; 82 | } 83 | 84 | /** 85 | * Returns whether the getter is excluded. 86 | */ 87 | private function isExcluded(ReflectionMethod $method): bool 88 | { 89 | $exclude = $this->reader->getAttribute($method, Exclude::class); 90 | 91 | return $exclude !== null; 92 | } 93 | 94 | /** 95 | * Get a GraphQL type instance from PHP type hinted type, possibly looking up the content of collections. 96 | */ 97 | final protected function getTypeFromReturnTypeHint(ReflectionMethod $method, string $fieldName): ?Type 98 | { 99 | $returnType = $method->getReturnType(); 100 | if (!$returnType instanceof ReflectionNamedType) { 101 | return null; 102 | } 103 | 104 | $returnTypeName = $returnType->getName(); 105 | if (is_a($returnTypeName, Collection::class, true) || $returnTypeName === 'array') { 106 | $targetEntity = $this->getTargetEntity($fieldName); 107 | if (!$targetEntity) { 108 | throw new Exception('The method ' . $this->getMethodFullName($method) . ' is type hinted with a return type of `' . $returnTypeName . '`, but the entity contained in that collection could not be automatically detected. Either fix the type hint, fix the doctrine mapping, or specify the type with `#[API\Field]` attribute.'); 109 | } 110 | 111 | $type = Type::listOf(Type::nonNull($this->getTypeFromRegistry($targetEntity, false))); 112 | if (!$returnType->allowsNull()) { 113 | $type = Type::nonNull($type); 114 | } 115 | 116 | return $type; 117 | } 118 | 119 | return $this->reflectionTypeToType($returnType); 120 | } 121 | 122 | /** 123 | * Convert a reflected type to GraphQL Type. 124 | */ 125 | final protected function reflectionTypeToType(ReflectionNamedType $reflectionType, bool $isEntityId = false): Type 126 | { 127 | $name = $reflectionType->getName(); 128 | if ($name === 'self') { 129 | $name = $this->metadata->name; 130 | } 131 | 132 | $type = $this->getTypeFromRegistry($name, $isEntityId); 133 | if (!$reflectionType->allowsNull()) { 134 | $type = Type::nonNull($type); 135 | } 136 | 137 | // @phpstan-ignore-next-line 138 | return $type; 139 | } 140 | 141 | /** 142 | * Look up which field is the ID. 143 | * 144 | * @param class-string $className 145 | */ 146 | private function findIdentityField(string $className): void 147 | { 148 | $this->metadata = $this->entityManager->getClassMetadata($className); 149 | foreach ($this->metadata->fieldMappings as $meta) { 150 | if ($meta->id ?? false) { 151 | $this->identityField = $meta->fieldName; 152 | } 153 | } 154 | } 155 | 156 | /** 157 | * Returns the fully qualified method name. 158 | */ 159 | final protected function getMethodFullName(ReflectionMethod $method): string 160 | { 161 | return '`' . $method->getDeclaringClass()->getName() . '::' . $method->getName() . '()`'; 162 | } 163 | 164 | /** 165 | * Throws exception if type is an array. 166 | */ 167 | final protected function throwIfArray(ReflectionParameter $param, ?string $type): void 168 | { 169 | if ($type === 'array') { 170 | throw new Exception('The parameter `$' . $param->getName() . '` on method ' . $this->getMethodFullName($param->getDeclaringFunction()) . ' is type hinted as `array` and is not overridden via `#[API\Argument]` attribute. Either change the type hint or specify the type with `#[API\Argument]` attribute.'); 171 | } 172 | } 173 | 174 | /** 175 | * Returns whether the given field name is the identity for the entity. 176 | */ 177 | final protected function isIdentityField(string $fieldName): bool 178 | { 179 | return $this->identityField === $fieldName; 180 | } 181 | 182 | /** 183 | * Finds the target entity in the given association. 184 | */ 185 | private function getTargetEntity(string $fieldName): ?string 186 | { 187 | return $this->metadata->associationMappings[$fieldName]->targetEntity ?? null; 188 | } 189 | 190 | /** 191 | * Return the default value, if any, of the property for the current entity. 192 | * 193 | * It does take into account that the property might be defined on a parent class 194 | * of entity. And it will find it if that is the case. 195 | */ 196 | final protected function getPropertyDefaultValue(string $fieldName): mixed 197 | { 198 | $property = $this->metadata->getReflectionProperties()[$fieldName] ?? null; 199 | if (!$property) { 200 | return null; 201 | } 202 | 203 | return $property->getDeclaringClass()->getDefaultProperties()[$fieldName] ?? null; 204 | } 205 | 206 | /** 207 | * Input with default values cannot be non-null. 208 | */ 209 | final protected function nonNullIfHasDefault(AbstractAttribute $attribute): void 210 | { 211 | $type = $attribute->getTypeInstance(); 212 | if ($type instanceof NonNull && $attribute->hasDefaultValue()) { 213 | $attribute->setTypeInstance($type->getWrappedType()); 214 | } 215 | } 216 | 217 | /** 218 | * Throws exception if argument type is invalid. 219 | */ 220 | final protected function throwIfNotInputType(ReflectionParameter $param, AbstractAttribute $attribute): void 221 | { 222 | $type = $attribute->getTypeInstance(); 223 | $class = new ReflectionClass($attribute); 224 | $attributeName = $class->getShortName(); 225 | 226 | if (!$type) { 227 | throw new Exception('Could not find type for parameter `$' . $param->name . '` for method ' . $this->getMethodFullName($param->getDeclaringFunction()) . '. Either type hint the parameter, or specify the type with `#[API\\' . $attributeName . ']` attribute.'); 228 | } 229 | 230 | if ($type instanceof WrappingType) { 231 | $type = $type->getInnermostType(); 232 | } 233 | 234 | if (!($type instanceof InputType)) { 235 | throw new Exception('Type for parameter `$' . $param->name . '` for method ' . $this->getMethodFullName($param->getDeclaringFunction()) . ' must be an instance of `' . InputType::class . '`, but was `' . $type::class . '`. Use `#[API\\' . $attributeName . ']` attribute to specify a custom InputType.'); 236 | } 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/Factory/FilteredQueryBuilderFactory.php: -------------------------------------------------------------------------------- 1 | uniqueNameFactory = new UniqueNameFactory(); 46 | $alias = $this->uniqueNameFactory->createAliasName($className); 47 | $this->dqlConditions = []; 48 | $this->uniqueJoins = []; 49 | 50 | $repository = $this->entityManager->getRepository($className); 51 | $this->queryBuilder = $repository->createQueryBuilder($alias); 52 | $metadata = $this->entityManager->getClassMetadata($className); 53 | $type = $this->types->getFilter($className); 54 | 55 | $this->applyGroups($metadata, $type, $filter, $alias); 56 | $this->applySorting($metadata, $className, $sorting, $alias); 57 | 58 | return $this->queryBuilder; 59 | } 60 | 61 | /** 62 | * Apply filters to the query builder. 63 | */ 64 | private function applyGroups(ClassMetadata $metadata, InputObjectType $type, array $filter, string $alias): void 65 | { 66 | /** @var ListOfType $groups */ 67 | $groups = $type->getField('groups')->getType(); 68 | 69 | /** @var InputObjectType $unwrapped */ 70 | $unwrapped = $groups->getInnermostType(); 71 | 72 | /** @var ListOfType $conditions */ 73 | $conditions = $unwrapped->getField('conditions')->getType(); 74 | 75 | /** @var InputObjectType $typeFields */ 76 | $typeFields = $conditions->getInnermostType(); 77 | foreach ($filter['groups'] ?? [] as $group) { 78 | $this->applyJoinsAndFilters($metadata, $alias, $typeFields, $group['joins'] ?? [], $group['conditions'] ?? []); 79 | $this->applyCollectedDqlConditions($group); 80 | } 81 | } 82 | 83 | /** 84 | * Apply both joins and filters to the query builder. 85 | */ 86 | private function applyJoinsAndFilters(ClassMetadata $metadata, string $alias, InputObjectType $typeFields, array $joins, array $conditions): void 87 | { 88 | $this->applyJoins($metadata, $joins, $alias); 89 | $this->collectDqlConditions($metadata, $conditions, $typeFields, $alias); 90 | } 91 | 92 | /** 93 | * Gather all DQL conditions for the given array of fields. 94 | */ 95 | private function collectDqlConditions(ClassMetadata $metadata, array $allConditions, InputObjectType $typeFields, string $alias): void 96 | { 97 | foreach ($allConditions as $conditions) { 98 | foreach ($conditions as $field => $operators) { 99 | if ($operators === null) { 100 | continue; 101 | } 102 | 103 | /** @var InputObjectType $typeField */ 104 | $typeField = $typeFields->getField($field)->getType(); 105 | 106 | foreach ($operators as $operatorName => $operatorArgs) { 107 | $operatorField = $typeField->getField($operatorName); 108 | 109 | /** @var AbstractOperator $operatorType */ 110 | $operatorType = $operatorField->getType(); 111 | 112 | $dqlCondition = $operatorType->getDqlCondition($this->uniqueNameFactory, $metadata, $this->queryBuilder, $alias, $field, $operatorArgs); 113 | if ($dqlCondition) { 114 | $this->dqlConditions[] = $dqlCondition; 115 | } 116 | } 117 | } 118 | } 119 | } 120 | 121 | /** 122 | * Apply joins to the query builder. 123 | */ 124 | private function applyJoins(ClassMetadata $metadata, array $joins, string $alias): void 125 | { 126 | foreach ($joins as $field => $join) { 127 | $joinedAlias = $this->createJoin($alias, $field, $join['type']); 128 | 129 | if (isset($join['joins']) || isset($join['conditions'])) { 130 | $targetClassName = $metadata->getAssociationMapping($field)->targetEntity; 131 | $targetMetadata = $this->entityManager->getClassMetadata($targetClassName); 132 | $type = $this->types->getFilterGroupCondition($targetClassName); 133 | $this->applyJoinsAndFilters($targetMetadata, $joinedAlias, $type, $join['joins'] ?? [], $join['conditions'] ?? []); 134 | } 135 | } 136 | } 137 | 138 | /** 139 | * Apply sorting to the query builder. 140 | * 141 | * @param class-string $className 142 | */ 143 | private function applySorting(ClassMetadata $metadata, string $className, array $sorting, string $alias): void 144 | { 145 | foreach ($sorting as $sort) { 146 | $customSort = $this->sortingTypeFactory->getCustomSorting($className, $sort['field']); 147 | if ($customSort) { 148 | $customSort($this->uniqueNameFactory, $metadata, $this->queryBuilder, $alias, $sort['order']); 149 | } else { 150 | $sortingField = $alias . '.' . $sort['field']; 151 | if ($sort['nullAsHighest'] ?? false) { 152 | $expression = 'CASE WHEN ' . $sortingField . ' IS NULL THEN 1 ELSE 0 END'; 153 | $sortingFieldNullAsHighest = $this->uniqueNameFactory->createAliasName('sorting'); 154 | $this->queryBuilder->addSelect($expression . ' AS HIDDEN ' . $sortingFieldNullAsHighest); 155 | $this->queryBuilder->addOrderBy($sortingFieldNullAsHighest, $sort['order']); 156 | } 157 | 158 | if ($sort['emptyStringAsHighest'] ?? false) { 159 | $expression = 'CASE WHEN ' . $sortingField . ' = \'\' THEN 1 ELSE 0 END'; 160 | $sortingFieldEmptyStringAsHighest = $this->uniqueNameFactory->createAliasName('sorting'); 161 | $this->queryBuilder->addSelect($expression . ' AS HIDDEN ' . $sortingFieldEmptyStringAsHighest); 162 | $this->queryBuilder->addOrderBy($sortingFieldEmptyStringAsHighest, $sort['order']); 163 | } 164 | 165 | $this->queryBuilder->addOrderBy($sortingField, $sort['order']); 166 | } 167 | } 168 | } 169 | 170 | /** 171 | * Apply collected DQL conditions on the query builder and reset them. 172 | */ 173 | private function applyCollectedDqlConditions(array $group): void 174 | { 175 | if (!$this->dqlConditions) { 176 | return; 177 | } 178 | 179 | if ($group['conditionsLogic'] === 'AND') { 180 | $fieldsDql = $this->queryBuilder->expr()->andX(...$this->dqlConditions); 181 | } else { 182 | $fieldsDql = $this->queryBuilder->expr()->orX(...$this->dqlConditions); 183 | } 184 | 185 | if ($group['groupLogic'] === 'AND') { 186 | $this->queryBuilder->andWhere($fieldsDql); 187 | } else { 188 | $this->queryBuilder->orWhere($fieldsDql); 189 | } 190 | 191 | $this->dqlConditions = []; 192 | } 193 | 194 | /** 195 | * Create a join, but only if it does not exist yet. 196 | */ 197 | private function createJoin(string $alias, string $field, string $joinType): string 198 | { 199 | $relationship = $alias . '.' . $field; 200 | $key = $relationship . '.' . $joinType; 201 | 202 | if (!isset($this->uniqueJoins[$key])) { 203 | $joinedAlias = $this->uniqueNameFactory->createAliasName($field); 204 | 205 | if ($joinType === 'innerJoin') { 206 | $this->queryBuilder->innerJoin($relationship, $joinedAlias); 207 | } else { 208 | $this->queryBuilder->leftJoin($relationship, $joinedAlias); 209 | } 210 | 211 | // TODO: For now we assume the query will always access some field on the relation, so we optimize SQL by 212 | // fetching those objects in a single SQL query. But this should be revisited by either exposing an option 213 | // to the API so the client could decide to select or not the relations, or even better to detect in GraphQL 214 | // query if it's actually used or not. 215 | $this->queryBuilder->addSelect($joinedAlias); 216 | 217 | $this->uniqueJoins[$key] = $joinedAlias; 218 | } 219 | 220 | return $this->uniqueJoins[$key]; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/Factory/InputFieldsConfigurationFactory.php: -------------------------------------------------------------------------------- 1 | getParameters(); 30 | if (count($params) !== 1) { 31 | return null; 32 | } 33 | $param = reset($params); 34 | 35 | // Get a field from attribute, or an empty one 36 | $field = $this->reader->getAttribute($method, Input::class) ?? new Input(); 37 | 38 | if (!$field->getTypeInstance()) { 39 | $this->convertTypeDeclarationsToInstances($method, $field); 40 | $this->completeField($field, $method, $param); 41 | } 42 | 43 | return $field->toArray(); 44 | } 45 | 46 | /** 47 | * All its types will be converted from string to real instance of Type. 48 | */ 49 | private function convertTypeDeclarationsToInstances(ReflectionMethod $method, Input $field): void 50 | { 51 | $field->setTypeInstance($this->getTypeFromPhpDeclaration($method->getDeclaringClass(), $field->getType())); 52 | } 53 | 54 | /** 55 | * Complete field with info from doc blocks and type hints. 56 | */ 57 | private function completeField(Input $field, ReflectionMethod $method, ReflectionParameter $param): void 58 | { 59 | $fieldName = lcfirst(preg_replace('~^set~', '', $method->getName()) ?? ''); 60 | if (!$field->getName()) { 61 | $field->setName($fieldName); 62 | } 63 | 64 | $docBlock = new DocBlockReader($method); 65 | if (!$field->getDescription()) { 66 | $field->setDescription($docBlock->getMethodDescription()); 67 | } 68 | 69 | $this->completeFieldDefaultValue($field, $param, $fieldName); 70 | $this->completeFieldType($field, $method, $param, $docBlock); 71 | } 72 | 73 | /** 74 | * Complete field default value from argument and property. 75 | */ 76 | private function completeFieldDefaultValue(Input $field, ReflectionParameter $param, string $fieldName): void 77 | { 78 | if (!$field->hasDefaultValue() && $param->isDefaultValueAvailable()) { 79 | $field->setDefaultValue($param->getDefaultValue()); 80 | } 81 | 82 | if (!$field->hasDefaultValue()) { 83 | $defaultValue = $this->getPropertyDefaultValue($fieldName); 84 | if ($defaultValue !== null) { 85 | $field->setDefaultValue($defaultValue); 86 | } 87 | } 88 | } 89 | 90 | /** 91 | * Complete field type from doc blocks and type hints. 92 | */ 93 | private function completeFieldType(Input $field, ReflectionMethod $method, ReflectionParameter $param, DocBlockReader $docBlock): void 94 | { 95 | // If still no type, look for docBlock 96 | if (!$field->getTypeInstance()) { 97 | $typeDeclaration = $docBlock->getParameterType($param); 98 | $this->throwIfArray($param, $typeDeclaration); 99 | $field->setTypeInstance($this->getTypeFromPhpDeclaration($method->getDeclaringClass(), $typeDeclaration, true)); 100 | } 101 | 102 | // If still no type, look for type hint 103 | $type = $param->getType(); 104 | if (!$field->getTypeInstance() && $type instanceof ReflectionNamedType) { 105 | $this->throwIfArray($param, $type->getName()); 106 | $field->setTypeInstance($this->reflectionTypeToType($type, true)); 107 | } 108 | 109 | $this->nonNullIfHasDefault($field); 110 | 111 | // If still no type, cannot continue 112 | $this->throwIfNotInputType($param, $field); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Factory/OutputFieldsConfigurationFactory.php: -------------------------------------------------------------------------------- 1 | reader->getAttribute($method, Field::class) ?? new Field(); 33 | 34 | if (!$field->type instanceof Type) { 35 | $field->type = $this->getTypeFromPhpDeclaration($method->getDeclaringClass(), $field->type); 36 | $this->completeField($field, $method); 37 | } 38 | 39 | return $field->toArray(); 40 | } 41 | 42 | /** 43 | * Complete field with info from doc blocks and type hints. 44 | */ 45 | private function completeField(Field $field, ReflectionMethod $method): void 46 | { 47 | $fieldName = lcfirst(preg_replace('~^get~', '', $method->getName()) ?? ''); 48 | if (!$field->name) { 49 | $field->name = $fieldName; 50 | } 51 | 52 | $docBlock = new DocBlockReader($method); 53 | if (!$field->description) { 54 | $field->description = $docBlock->getMethodDescription(); 55 | } 56 | 57 | $this->completeFieldArguments($field, $method, $docBlock); 58 | $this->completeFieldType($field, $method, $fieldName, $docBlock); 59 | } 60 | 61 | /** 62 | * Complete arguments configuration from existing type hints. 63 | */ 64 | private function completeFieldArguments(Field $field, ReflectionMethod $method, DocBlockReader $docBlock): void 65 | { 66 | $args = []; 67 | foreach ($method->getParameters() as $param) { 68 | // Either get existing, or create new argument 69 | $arg = $this->reader->getAttribute($param, Argument::class) ?? new Argument(); 70 | $arg->setName($param->getName()); 71 | 72 | $arg->setTypeInstance($this->getTypeFromPhpDeclaration($method->getDeclaringClass(), $arg->getType())); 73 | 74 | $args[$param->getName()] = $arg; 75 | 76 | $this->completeArgumentFromTypeHint($arg, $method, $param, $docBlock); 77 | } 78 | 79 | $field->args = $args; 80 | } 81 | 82 | /** 83 | * Complete a single argument from its type hint. 84 | */ 85 | private function completeArgumentFromTypeHint(Argument $arg, ReflectionMethod $method, ReflectionParameter $param, DocBlockReader $docBlock): void 86 | { 87 | if (!$arg->getDescription()) { 88 | $arg->setDescription($docBlock->getParameterDescription($param)); 89 | } 90 | 91 | if (!$arg->hasDefaultValue() && $param->isDefaultValueAvailable()) { 92 | $arg->setDefaultValue($param->getDefaultValue()); 93 | } 94 | 95 | $this->completeArgumentTypeFromTypeHint($arg, $method, $param, $docBlock); 96 | } 97 | 98 | /** 99 | * Complete a single argument type from its type hint and doc block. 100 | */ 101 | private function completeArgumentTypeFromTypeHint(Argument $arg, ReflectionMethod $method, ReflectionParameter $param, DocBlockReader $docBlock): void 102 | { 103 | if (!$arg->getTypeInstance()) { 104 | $typeDeclaration = $docBlock->getParameterType($param); 105 | $this->throwIfArray($param, $typeDeclaration); 106 | $arg->setTypeInstance($this->getTypeFromPhpDeclaration($method->getDeclaringClass(), $typeDeclaration, true)); 107 | } 108 | 109 | $type = $param->getType(); 110 | if (!$arg->getTypeInstance() && $type instanceof ReflectionNamedType) { 111 | $this->throwIfArray($param, $type->getName()); 112 | $arg->setTypeInstance($this->reflectionTypeToType($type, true)); 113 | } 114 | 115 | $this->nonNullIfHasDefault($arg); 116 | 117 | $this->throwIfNotInputType($param, $arg); 118 | } 119 | 120 | /** 121 | * Get a GraphQL type instance from dock block return type. 122 | */ 123 | private function getTypeFromDocBock(ReflectionMethod $method, DocBlockReader $docBlock): ?Type 124 | { 125 | $typeDeclaration = $docBlock->getReturnType(); 126 | $blacklist = [ 127 | 'Collection', 128 | 'array', 129 | ]; 130 | 131 | if ($typeDeclaration && !in_array($typeDeclaration, $blacklist, true)) { 132 | return $this->getTypeFromPhpDeclaration($method->getDeclaringClass(), $typeDeclaration); 133 | } 134 | 135 | return null; 136 | } 137 | 138 | /** 139 | * Complete field type from doc blocks and type hints. 140 | */ 141 | private function completeFieldType(Field $field, ReflectionMethod $method, string $fieldName, DocBlockReader $docBlock): void 142 | { 143 | if ($this->isIdentityField($fieldName)) { 144 | $field->type = Type::nonNull(Type::id()); 145 | } 146 | 147 | // If still no type, look for docBlock 148 | if (!$field->type) { 149 | $field->type = $this->getTypeFromDocBock($method, $docBlock); 150 | } 151 | 152 | // If still no type, look for type hint 153 | if (!$field->type) { 154 | $field->type = $this->getTypeFromReturnTypeHint($method, $fieldName); 155 | } 156 | 157 | // If still no type, cannot continue 158 | if (!$field->type) { 159 | throw new Exception('Could not find type for method ' . $this->getMethodFullName($method) . '. Either type hint the return value, or specify the type with `#[API\Field]` attribute.'); 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/Factory/Type/AbstractTypeFactory.php: -------------------------------------------------------------------------------- 1 | getDocComment() ?: ''; 36 | 37 | // Remove the comment markers 38 | $comment = preg_replace('~\*/$~', '', $comment); 39 | $comment = preg_replace('~^\s*(/\*\*|\* ?|\*/)~m', '', $comment); 40 | 41 | // Keep everything before the first annotation 42 | $comment = trim(explode('@', $comment ?? '')[0]); 43 | 44 | if (!$comment) { 45 | $comment = null; 46 | } 47 | 48 | return $comment; 49 | } 50 | 51 | /** 52 | * Throw an exception if the given type does not inherit expected type. 53 | */ 54 | final protected function throwIfInvalidAttribute(string $classWithAttribute, string $attribute, string $expectedClassName, string $actualClassName): void 55 | { 56 | if (!is_a($actualClassName, $expectedClassName, true)) { 57 | throw new Exception('On class `' . $classWithAttribute . '` the attribute `#[API\\' . $attribute . ']` expects a FQCN implementing `' . $expectedClassName . '`, but instead got: ' . $actualClassName); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Factory/Type/EntityIDTypeFactory.php: -------------------------------------------------------------------------------- 1 | entityManager->getClassMetadata($className)->getIdentifier(); 25 | if (count($identifiers) > 1) { 26 | throw new Exception('Entities with composite identifiers are not supported by graphql-doctrine. The entity `' . $className . '` cannot be used as input type.'); 27 | } 28 | 29 | return new EntityIDType($this->entityManager, $className, $typeName); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Factory/Type/FilterGroupConditionTypeFactory.php: -------------------------------------------------------------------------------- 1 | $typeName, 53 | 'description' => 'Type to specify conditions on fields', 54 | 'fields' => function () use ($className, $typeName): array { 55 | $filters = []; 56 | $metadata = $this->entityManager->getClassMetadata($className); 57 | 58 | // Get custom operators 59 | // @phpstan-ignore-next-line 60 | $this->readCustomOperatorsFromAttribute($metadata->reflClass); 61 | 62 | // Get all scalar fields 63 | foreach ($metadata->fieldMappings as $mapping) { 64 | $fieldName = $mapping->fieldName; 65 | $property = $metadata->getReflectionProperty($fieldName); 66 | 67 | // Skip exclusion specified by user 68 | if ($this->isPropertyExcluded($property)) { 69 | continue; 70 | } 71 | 72 | $leafType = $this->getLeafType($property, $mapping); 73 | $operators = $this->getOperators($fieldName, $leafType, false, false); 74 | 75 | $filters[] = $this->getFieldConfiguration($typeName, $fieldName, $operators); 76 | } 77 | 78 | // Get all collection fields 79 | foreach ($metadata->associationMappings as $mapping) { 80 | $fieldName = $mapping->fieldName; 81 | $operators = $this->getOperators($fieldName, Type::id(), true, $metadata->isCollectionValuedAssociation($fieldName)); 82 | 83 | $filters[] = $this->getFieldConfiguration($typeName, $fieldName, $operators); 84 | } 85 | 86 | // Get all custom fields defined by custom operators 87 | foreach ($this->customOperators as $fieldName => $customOperators) { 88 | $operators = []; 89 | foreach ($customOperators as $customOperator) { 90 | /** @var LeafType $leafType */ 91 | $leafType = $this->types->get($customOperator->type); 92 | $operator = $customOperator->operator; 93 | $operators[$operator] = $leafType; 94 | } 95 | 96 | $filters[] = $this->getFieldConfiguration($typeName, $fieldName, $operators); 97 | } 98 | 99 | return $filters; 100 | }, 101 | ]); 102 | 103 | return $type; 104 | } 105 | 106 | /** 107 | * Read the type of the filterGroupCondition, either from Doctrine mapping type, or the override via attribute. 108 | */ 109 | private function getLeafType(ReflectionProperty $property, FieldMapping $mapping): LeafType 110 | { 111 | if ($mapping->id ?? false) { 112 | return Type::id(); 113 | } 114 | 115 | $filterGroupCondition = $this->reader->getAttribute($property, FilterGroupCondition::class); 116 | if ($filterGroupCondition) { 117 | $leafType = $this->getTypeFromPhpDeclaration($property->getDeclaringClass(), $filterGroupCondition->type); 118 | 119 | if ($leafType) { 120 | if (!$leafType instanceof LeafType) { 121 | $propertyFullName = '`' . $property->getDeclaringClass()->getName() . '::$' . $property->getName() . '`'; 122 | 123 | throw new Exception('On property ' . $propertyFullName . ' the attribute `#[API\\FilterGroupCondition]` expects a, possibly wrapped, `' . LeafType::class . '`, but instead got: ' . $leafType::class); 124 | } 125 | 126 | return $leafType; 127 | } 128 | } 129 | 130 | /** @var LeafType $leafType */ 131 | $leafType = $this->types->get($mapping->enumType ?? $mapping->type); 132 | 133 | return $leafType; 134 | } 135 | 136 | /** 137 | * Get the field for conditions on all fields. 138 | * 139 | * @param class-string $className 140 | */ 141 | public function getField(string $className): array 142 | { 143 | $conditionFieldsType = $this->types->getFilterGroupCondition($className); 144 | 145 | $field = [ 146 | 'name' => 'conditions', 147 | 'description' => 'Conditions to be applied on fields', 148 | 'type' => Type::listOf(Type::nonNull($conditionFieldsType)), 149 | ]; 150 | 151 | return $field; 152 | } 153 | 154 | /** 155 | * Get the custom operators declared on the class via attributes indexed by their field name. 156 | */ 157 | private function readCustomOperatorsFromAttribute(ReflectionClass $class): void 158 | { 159 | $allFilters = $this->reader->getRecursiveClassAttributes($class, Filter::class); 160 | $this->customOperators = []; 161 | foreach ($allFilters as $classWithAttribute => $filters) { 162 | foreach ($filters as $filter) { 163 | $className = $filter->operator; 164 | $this->throwIfInvalidAttribute($classWithAttribute, 'Filter', AbstractOperator::class, $className); 165 | 166 | if (!isset($this->customOperators[$filter->field])) { 167 | $this->customOperators[$filter->field] = []; 168 | } 169 | $this->customOperators[$filter->field][] = $filter; 170 | } 171 | } 172 | } 173 | 174 | /** 175 | * Get configuration for field. 176 | * 177 | * @param array, LeafType> $operators 178 | */ 179 | private function getFieldConfiguration(string $typeName, string $fieldName, array $operators): array 180 | { 181 | return [ 182 | 'name' => $fieldName, 183 | 'type' => $this->getFieldType($typeName, $fieldName, $operators), 184 | ]; 185 | } 186 | 187 | /** 188 | * Return a map of operator class name and their leaf type, including custom operator for the given fieldName. 189 | * 190 | * @return array, LeafType> indexed by operator class name 191 | */ 192 | private function getOperators(string $fieldName, LeafType $leafType, bool $isAssociation, bool $isCollection): array 193 | { 194 | // For a single pure scalar 195 | $operatorKeys = []; 196 | if (!$isAssociation && !$isCollection) { 197 | array_push($operatorKeys, ...[ 198 | LikeOperatorType::class, 199 | ]); 200 | } 201 | 202 | // An association can share some operators independently if it's a single entity or collection of entities 203 | if ($isAssociation) { 204 | array_push($operatorKeys, ...[ 205 | HaveOperatorType::class, 206 | EmptyOperatorType::class, 207 | ]); 208 | } 209 | 210 | // We can share most operators for scalar and single entity association 211 | if (!$isCollection) { 212 | array_push($operatorKeys, ...[ 213 | BetweenOperatorType::class, 214 | EqualOperatorType::class, 215 | GreaterOperatorType::class, 216 | GreaterOrEqualOperatorType::class, 217 | InOperatorType::class, 218 | LessOperatorType::class, 219 | LessOrEqualOperatorType::class, 220 | NullOperatorType::class, 221 | GroupOperatorType::class, 222 | ]); 223 | } 224 | 225 | $operators = array_fill_keys($operatorKeys, $leafType); 226 | 227 | // Add custom filters if any 228 | if (isset($this->customOperators[$fieldName])) { 229 | foreach ($this->customOperators[$fieldName] as $filter) { 230 | /** @var LeafType $leafType */ 231 | $leafType = $this->types->get($filter->type); 232 | $operator = $filter->operator; 233 | $operators[$operator] = $leafType; 234 | } 235 | 236 | unset($this->customOperators[$fieldName]); 237 | } 238 | 239 | return $operators; 240 | } 241 | 242 | /** 243 | * Get the type for a specific field. 244 | * 245 | * @param array, LeafType> $operators 246 | */ 247 | private function getFieldType(string $typeName, string $fieldName, array $operators): InputObjectType 248 | { 249 | $fieldTypeName = $typeName . ucfirst($fieldName); 250 | if ($this->types->has($fieldTypeName)) { 251 | // @phpstan-ignore-next-line 252 | return $this->types->get($fieldTypeName); 253 | } 254 | 255 | $fieldType = new InputObjectType([ 256 | 'name' => $fieldTypeName, 257 | 'description' => 'Type to specify a condition on a specific field', 258 | 'fields' => $this->getOperatorConfiguration($operators), 259 | ]); 260 | 261 | $this->types->registerInstance($fieldType); 262 | 263 | return $fieldType; 264 | } 265 | 266 | /** 267 | * Get operators configuration for a specific leaf type. 268 | * 269 | * @param array, LeafType> $operators 270 | */ 271 | private function getOperatorConfiguration(array $operators): array 272 | { 273 | $conf = []; 274 | foreach ($operators as $operator => $leafType) { 275 | $instance = $this->types->getOperator($operator, $leafType); 276 | $field = [ 277 | 'name' => $this->getOperatorFieldName($operator), 278 | 'type' => $instance, 279 | ]; 280 | 281 | $conf[] = $field; 282 | } 283 | 284 | return $conf; 285 | } 286 | 287 | /** 288 | * Get the name for the operator to be used as field name. 289 | * 290 | * @param class-string $className 291 | */ 292 | private function getOperatorFieldName(string $className): string 293 | { 294 | $name = preg_replace('~OperatorType$~', '', Utils::getTypeName($className)); 295 | 296 | return lcfirst($name ?? ''); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/Factory/Type/FilterGroupJoinTypeFactory.php: -------------------------------------------------------------------------------- 1 | $typeName, 27 | 'description' => 'Type to specify join tables in a filter', 28 | 'fields' => fn (): array => $this->getJoinsFields($className), 29 | ]); 30 | 31 | return $type; 32 | } 33 | 34 | /** 35 | * Get the field for joins. 36 | * 37 | * @param class-string $className 38 | */ 39 | public function getField(string $className): array 40 | { 41 | $joinsType = $this->types->getFilterGroupJoin($className); 42 | 43 | $joinsField = [ 44 | 'name' => 'joins', 45 | 'description' => 'Optional joins to either filter the query or fetch related objects from DB in a single query', 46 | 'type' => $joinsType, 47 | ]; 48 | 49 | return $joinsField; 50 | } 51 | 52 | /** 53 | * Get the all the possible relations to be joined. 54 | * 55 | * @param class-string $className 56 | */ 57 | private function getJoinsFields(string $className): array 58 | { 59 | $fields = []; 60 | $associations = $this->entityManager->getClassMetadata($className)->associationMappings; 61 | foreach ($associations as $association) { 62 | $field = [ 63 | 'name' => $association->fieldName, 64 | 'type' => $this->types->getJoinOn($association->targetEntity), 65 | ]; 66 | 67 | $fields[] = $field; 68 | } 69 | 70 | return $fields; 71 | } 72 | 73 | /** 74 | * Return whether it is possible to create a valid type for join. 75 | * 76 | * @param class-string $className 77 | */ 78 | public function canCreate(string $className): bool 79 | { 80 | return !empty($this->entityManager->getClassMetadata($className)->associationMappings); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Factory/Type/FilterTypeFactory.php: -------------------------------------------------------------------------------- 1 | 'groups', 41 | 'type' => Type::listOf(Type::nonNull($this->getGroupType($className, $typeName))), 42 | ], 43 | ]; 44 | 45 | return $configuration; 46 | }; 47 | 48 | $filterType = new InputObjectType([ 49 | 'name' => $typeName, 50 | 'description' => $description, 51 | 'fields' => $fieldsGetter, 52 | ]); 53 | $this->types->registerInstance($filterType); 54 | 55 | return $filterType; 56 | } 57 | 58 | /** 59 | * Get the type for condition. 60 | * 61 | * @param class-string $className 62 | */ 63 | private function getGroupType(string $className, string $typeName): InputObjectType 64 | { 65 | $groupTypeName = $typeName . 'Group'; 66 | if ($this->types->has($groupTypeName)) { 67 | // @phpstan-ignore-next-line 68 | return $this->types->get($groupTypeName); 69 | } 70 | 71 | $fields = [ 72 | [ 73 | 'name' => 'groupLogic', 74 | 'type' => $this->types->get('LogicalOperator'), 75 | 'description' => 'The logic operator to be used to append this group', 76 | 'defaultValue' => 'AND', 77 | ], 78 | [ 79 | 'name' => 'conditionsLogic', 80 | 'type' => $this->types->get('LogicalOperator'), 81 | 'description' => 'The logic operator to be used within all conditions in this group', 82 | 'defaultValue' => 'AND', 83 | ], 84 | $this->filterGroupConditionTypeFactory->getField($className), 85 | ]; 86 | 87 | // Only create join type, if there is anything to join on 88 | if ($this->filterGroupJoinTypeFactory->canCreate($className)) { 89 | $fields[] = $this->filterGroupJoinTypeFactory->getField($className); 90 | } 91 | 92 | $conditionType = new InputObjectType([ 93 | 'name' => $groupTypeName, 94 | 'description' => 'Specify a set of joins and conditions to filter `' . Utils::getTypeName($className) . '`', 95 | 'fields' => $fields, 96 | ]); 97 | $this->types->registerInstance($conditionType); 98 | 99 | return $conditionType; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Factory/Type/InputTypeFactory.php: -------------------------------------------------------------------------------- 1 | getDescription($className); 25 | 26 | $fieldsGetter = function () use ($className): array { 27 | $factory = new InputFieldsConfigurationFactory($this->types, $this->entityManager); 28 | $configuration = $factory->create($className); 29 | 30 | return $configuration; 31 | }; 32 | 33 | return new InputObjectType([ 34 | 'name' => $typeName, 35 | 'description' => $description, 36 | 'fields' => $fieldsGetter, 37 | ]); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Factory/Type/JoinOnTypeFactory.php: -------------------------------------------------------------------------------- 1 | $typeName, 38 | 'fields' => function () use ($className): array { 39 | $fields = [ 40 | [ 41 | 'name' => 'type', 42 | 'type' => $this->types->get('JoinType'), 43 | 'defaultValue' => 'innerJoin', 44 | ], 45 | $this->filterGroupConditionTypeFactory->getField($className), 46 | ]; 47 | 48 | // Only create join type, if there is anything to join on 49 | if ($this->filterGroupJoinTypeFactory->canCreate($className)) { 50 | $fields[] = $this->filterGroupJoinTypeFactory->getField($className); 51 | } 52 | 53 | return $fields; 54 | }, 55 | ]); 56 | 57 | return $type; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Factory/Type/ObjectTypeFactory.php: -------------------------------------------------------------------------------- 1 | getDescription($className); 27 | 28 | $fieldsGetter = function () use ($className): array { 29 | $factory = new OutputFieldsConfigurationFactory($this->types, $this->entityManager); 30 | $configuration = $factory->create($className); 31 | 32 | return $configuration; 33 | }; 34 | 35 | return new ObjectType([ 36 | 'name' => $typeName, 37 | 'description' => $description, 38 | 'fields' => $fieldsGetter, 39 | ]); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Factory/Type/PartialInputTypeFactory.php: -------------------------------------------------------------------------------- 1 | types->getInput($className); 26 | $fieldsGetter = $type->config['fields']; 27 | 28 | $optionalFieldsGetter = function () use ($fieldsGetter): array { 29 | $optionalFields = []; 30 | $fields = is_callable($fieldsGetter) ? $fieldsGetter() : $fieldsGetter; 31 | foreach ($fields as $field) { 32 | if ($field['type'] instanceof NonNull) { 33 | $field['type'] = $field['type']->getWrappedType(); 34 | } 35 | 36 | unset($field['defaultValue']); 37 | 38 | $optionalFields[] = $field; 39 | } 40 | 41 | return $optionalFields; 42 | }; 43 | $type->config['fields'] = $optionalFieldsGetter; 44 | $type->name = $typeName; 45 | 46 | return $type; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Factory/Type/SortingTypeFactory.php: -------------------------------------------------------------------------------- 1 | > 25 | */ 26 | private array $customSortings = []; 27 | 28 | /** 29 | * Create an InputObjectType from a Doctrine entity to 30 | * sort them by their fields and custom sorter. 31 | * 32 | * @param class-string $className class name of Doctrine entity 33 | * @param string $typeName GraphQL type name 34 | */ 35 | public function create(string $className, string $typeName): InputObjectType 36 | { 37 | $type = new InputObjectType([ 38 | 'name' => $typeName, 39 | 'fields' => function () use ($className, $typeName): array { 40 | $fieldsEnum = $this->getEnumType($typeName, $className); 41 | 42 | return [ 43 | [ 44 | 'name' => 'field', 45 | 'type' => Type::nonNull($fieldsEnum), 46 | ], 47 | [ 48 | 'name' => 'nullAsHighest', 49 | 'type' => Type::boolean(), 50 | 'description' => 'If true `NULL` values will be considered as the highest value, so appearing last in a `ASC` order, and first in a `DESC` order.', 51 | 'defaultValue' => false, 52 | ], 53 | [ 54 | 'name' => 'emptyStringAsHighest', 55 | 'type' => Type::boolean(), 56 | 'description' => 'If true empty strings (`""`) will be considered as the highest value, so appearing last in a `ASC` order, and first in a `DESC` order.', 57 | 'defaultValue' => false, 58 | ], 59 | [ 60 | 'name' => 'order', 61 | 'type' => $this->types->get('SortingOrder'), 62 | 'defaultValue' => 'ASC', 63 | ], 64 | ]; 65 | }, 66 | ]); 67 | 68 | return $type; 69 | } 70 | 71 | /** 72 | * Get names for all possible sorting, including the custom one. 73 | * 74 | * @param class-string $className 75 | * 76 | * @return string[] 77 | */ 78 | private function getPossibleValues(string $className): array 79 | { 80 | $metadata = $this->entityManager->getClassMetadata($className); 81 | $standard = array_values(array_filter($metadata->fieldNames, function ($fieldName) use ($metadata) { 82 | $property = $metadata->getReflectionProperty($fieldName); 83 | 84 | return !$this->isPropertyExcluded($property); 85 | })); 86 | $custom = $this->getCustomSortingNames($className); 87 | 88 | return array_merge($standard, $custom); 89 | } 90 | 91 | /** 92 | * Get names for all custom sorting. 93 | * 94 | * @param class-string $className 95 | * 96 | * @return string[] 97 | */ 98 | private function getCustomSortingNames(string $className): array 99 | { 100 | $this->fillCache($className); 101 | 102 | return array_keys($this->customSortings[$className]); 103 | } 104 | 105 | /** 106 | * Get instance of custom sorting for the given entity and sorting name. 107 | * 108 | * @param class-string $className 109 | */ 110 | public function getCustomSorting(string $className, string $name): ?SortingInterface 111 | { 112 | $this->fillCache($className); 113 | 114 | return $this->customSortings[$className][$name] ?? null; 115 | } 116 | 117 | /** 118 | * Fill the cache for custom sorting. 119 | * 120 | * @param class-string $className 121 | */ 122 | private function fillCache(string $className): void 123 | { 124 | if (array_key_exists($className, $this->customSortings)) { 125 | return; 126 | } 127 | 128 | $class = new ReflectionClass($className); 129 | $this->customSortings[$className] = $this->getFromAttribute($class); 130 | } 131 | 132 | /** 133 | * Get all instance of custom sorting from the attribute. 134 | * 135 | * @return array 136 | */ 137 | private function getFromAttribute(ReflectionClass $class): array 138 | { 139 | $allSortings = $this->reader->getRecursiveClassAttributes($class, Sorting::class); 140 | 141 | $result = []; 142 | foreach ($allSortings as $classWithAttribute => $sortings) { 143 | foreach ($sortings as $sorting) { 144 | $className = $sorting->class; 145 | $this->throwIfInvalidAttribute($classWithAttribute, 'Sorting', SortingInterface::class, $className); 146 | 147 | $name = lcfirst(Utils::getTypeName($className)); 148 | $result[$name] = new $className(); 149 | } 150 | } 151 | 152 | return $result; 153 | } 154 | 155 | /** 156 | * @param class-string $className 157 | */ 158 | private function getEnumType(string $typeName, string $className): EnumType 159 | { 160 | $enumTypeName = $typeName . 'Field'; 161 | if ($this->types->has($enumTypeName)) { 162 | // @phpstan-ignore-next-line 163 | return $this->types->get($enumTypeName); 164 | } 165 | 166 | $fieldsEnum = new EnumType([ 167 | 'name' => $enumTypeName, 168 | 'values' => $this->getPossibleValues($className), 169 | 'description' => 'Fields available for `' . $typeName . '`', 170 | ]); 171 | $this->types->registerInstance($fieldsEnum); 172 | 173 | return $fieldsEnum; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Factory/UniqueNameFactory.php: -------------------------------------------------------------------------------- 1 | parameterCount++; 27 | } 28 | 29 | /** 30 | * Return a string to be used as alias name in a query. 31 | */ 32 | public function createAliasName(string $className): string 33 | { 34 | $alias = lcfirst(preg_replace('~^.*\\\\~', '', $className) ?? ''); 35 | if (!isset($this->aliasCount[$alias])) { 36 | $this->aliasCount[$alias] = 1; 37 | } 38 | 39 | return $alias . $this->aliasCount[$alias]++; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Sorting/SortingInterface.php: -------------------------------------------------------------------------------- 1 | mapping of type name to type instances 43 | */ 44 | private array $types = []; 45 | 46 | private readonly ObjectTypeFactory $objectTypeFactory; 47 | 48 | private readonly InputTypeFactory $inputTypeFactory; 49 | 50 | private readonly PartialInputTypeFactory $partialInputTypeFactory; 51 | 52 | private readonly FilterTypeFactory $filterTypeFactory; 53 | 54 | private readonly FilteredQueryBuilderFactory $filteredQueryBuilderFactory; 55 | 56 | private readonly SortingTypeFactory $sortingTypeFactory; 57 | 58 | private readonly EntityIDTypeFactory $entityIDTypeFactory; 59 | 60 | private readonly JoinOnTypeFactory $joinOnTypeFactory; 61 | 62 | private readonly FilterGroupJoinTypeFactory $filterGroupJoinTypeFactory; 63 | 64 | private readonly FilterGroupConditionTypeFactory $filterGroupConditionTypeFactory; 65 | 66 | public function __construct(private readonly EntityManager $entityManager, private readonly ?ContainerInterface $customTypes = null) 67 | { 68 | $this->objectTypeFactory = new ObjectTypeFactory($this, $entityManager); 69 | $this->inputTypeFactory = new InputTypeFactory($this, $entityManager); 70 | $this->partialInputTypeFactory = new PartialInputTypeFactory($this, $entityManager); 71 | $this->sortingTypeFactory = new SortingTypeFactory($this, $entityManager); 72 | $this->entityIDTypeFactory = new EntityIDTypeFactory($this, $entityManager); 73 | $this->filterGroupJoinTypeFactory = new FilterGroupJoinTypeFactory($this, $entityManager); 74 | $this->filterGroupConditionTypeFactory = new FilterGroupConditionTypeFactory($this, $entityManager); 75 | $this->filteredQueryBuilderFactory = new FilteredQueryBuilderFactory($this, $entityManager, $this->sortingTypeFactory); 76 | $this->filterTypeFactory = new FilterTypeFactory($this, $entityManager, $this->filterGroupJoinTypeFactory, $this->filterGroupConditionTypeFactory); 77 | $this->joinOnTypeFactory = new JoinOnTypeFactory($this, $entityManager, $this->filterGroupJoinTypeFactory, $this->filterGroupConditionTypeFactory); 78 | 79 | $this->initializeInternalTypes(); 80 | } 81 | 82 | public function has(string $key): bool 83 | { 84 | return $this->customTypes && $this->customTypes->has($key) || array_key_exists($key, $this->types); 85 | } 86 | 87 | public function get(string $key): Type&NamedType 88 | { 89 | if ($this->customTypes && $this->customTypes->has($key)) { 90 | /** @var NamedType&Type $t */ 91 | $t = $this->customTypes->get($key); 92 | $this->registerInstance($t); 93 | 94 | return $t; 95 | } 96 | 97 | if (array_key_exists($key, $this->types)) { 98 | return $this->types[$key]; 99 | } 100 | 101 | throw new Exception('No type registered with key `' . $key . '`. Either correct the usage, or register it in your custom types container when instantiating `' . self::class . '`.'); 102 | } 103 | 104 | /** 105 | * Get a type from internal registry, and create it via the factory if needed. 106 | * 107 | * @param class-string $className 108 | */ 109 | private function getViaFactory(string $className, string $typeName, AbstractTypeFactory $factory): Type&NamedType 110 | { 111 | $this->throwIfNotEntity($className); 112 | 113 | if (!isset($this->types[$typeName])) { 114 | $instance = $factory->create($className, $typeName); 115 | $this->registerInstance($instance); 116 | } 117 | 118 | return $this->types[$typeName]; 119 | } 120 | 121 | public function getOutput(string $className): ObjectType 122 | { 123 | /** @var ObjectType $type */ 124 | $type = $this->getViaFactory($className, Utils::getTypeName($className), $this->objectTypeFactory); 125 | 126 | return $type; 127 | } 128 | 129 | public function getInput(string $className): InputObjectType 130 | { 131 | /** @var InputObjectType $type */ 132 | $type = $this->getViaFactory($className, Utils::getTypeName($className) . 'Input', $this->inputTypeFactory); 133 | 134 | return $type; 135 | } 136 | 137 | public function getPartialInput(string $className): InputObjectType 138 | { 139 | /** @var InputObjectType $type */ 140 | $type = $this->getViaFactory($className, Utils::getTypeName($className) . 'PartialInput', $this->partialInputTypeFactory); 141 | 142 | return $type; 143 | } 144 | 145 | public function getFilter(string $className): InputObjectType 146 | { 147 | /** @var InputObjectType $type */ 148 | $type = $this->getViaFactory($className, Utils::getTypeName($className) . 'Filter', $this->filterTypeFactory); 149 | 150 | return $type; 151 | } 152 | 153 | public function getSorting(string $className): ListOfType 154 | { 155 | /** @var InputObjectType $type */ 156 | $type = $this->getViaFactory($className, Utils::getTypeName($className) . 'Sorting', $this->sortingTypeFactory); 157 | 158 | return Type::listOf(Type::nonNull($type)); 159 | } 160 | 161 | /** 162 | * Returns a joinOn input type for the given entity. 163 | * 164 | * This is for internal use only. 165 | * 166 | * @param class-string $className the class name of an entity (`Post::class`) 167 | */ 168 | public function getJoinOn(string $className): InputObjectType 169 | { 170 | /** @var InputObjectType $type */ 171 | $type = $this->getViaFactory($className, 'JoinOn' . Utils::getTypeName($className), $this->joinOnTypeFactory); 172 | 173 | return $type; 174 | } 175 | 176 | /** 177 | * Returns a joins input type for the given entity. 178 | * 179 | * This is for internal use only. 180 | * 181 | * @param class-string $className the class name of an entity (`Post::class`) 182 | */ 183 | public function getFilterGroupJoin(string $className): InputObjectType 184 | { 185 | /** @var InputObjectType $type */ 186 | $type = $this->getViaFactory($className, Utils::getTypeName($className) . 'FilterGroupJoin', $this->filterGroupJoinTypeFactory); 187 | 188 | return $type; 189 | } 190 | 191 | /** 192 | * Returns a condition input type for the given entity. 193 | * 194 | * This is for internal use only. 195 | * 196 | * @param class-string $className the class name of an entity (`Post::class`) 197 | */ 198 | public function getFilterGroupCondition(string $className): InputObjectType 199 | { 200 | /** @var InputObjectType $type */ 201 | $type = $this->getViaFactory($className, Utils::getTypeName($className) . 'FilterGroupCondition', $this->filterGroupConditionTypeFactory); 202 | 203 | return $type; 204 | } 205 | 206 | public function getId(string $className): EntityIDType 207 | { 208 | /** @var EntityIDType $type */ 209 | $type = $this->getViaFactory($className, Utils::getTypeName($className) . 'ID', $this->entityIDTypeFactory); 210 | 211 | return $type; 212 | } 213 | 214 | /** 215 | * Returns an operator input type. 216 | * 217 | * This is for internal use only. 218 | * 219 | * @param class-string $className the class name of an operator (`EqualOperatorType::class`) 220 | */ 221 | public function getOperator(string $className, LeafType $type): AbstractOperator 222 | { 223 | if (!is_a($className, AbstractOperator::class, true)) { 224 | throw new Exception('Expects a FQCN implementing `' . AbstractOperator::class . '`, but instead got: ' . $className); 225 | } 226 | 227 | $key = Utils::getOperatorTypeName($className, $type); 228 | 229 | if (!isset($this->types[$key])) { 230 | $instance = new $className($this, $type); 231 | $this->registerInstance($instance); 232 | } 233 | 234 | return $this->types[$key]; 235 | } 236 | 237 | /** 238 | * Register the given type in our internal registry with its name. 239 | * 240 | * This is for internal use only. You should declare custom types via the constructor, not this method. 241 | */ 242 | public function registerInstance(Type&NamedType $instance): void 243 | { 244 | $this->types[$instance->name()] = $instance; 245 | } 246 | 247 | /** 248 | * Checks if a className is a valid doctrine entity. 249 | */ 250 | public function isEntity(string $className): bool 251 | { 252 | return class_exists($className) && !$this->entityManager->getMetadataFactory()->isTransient($className); 253 | } 254 | 255 | /** 256 | * Initialize internal types for common needs. 257 | */ 258 | private function initializeInternalTypes(): void 259 | { 260 | $phpToGraphQLMapping = [ 261 | // PHP types 262 | 'id' => Type::id(), 263 | 'bool' => Type::boolean(), 264 | 'int' => Type::int(), 265 | 'float' => Type::float(), 266 | 'string' => Type::string(), 267 | 268 | // Doctrine types 269 | 'boolean' => Type::boolean(), 270 | 'integer' => Type::int(), 271 | 'smallint' => Type::int(), 272 | 'bigint' => Type::int(), 273 | 'decimal' => Type::string(), 274 | 'text' => Type::string(), 275 | ]; 276 | 277 | $this->types = $phpToGraphQLMapping; 278 | $this->registerInstance(new LogicalOperatorType()); 279 | $this->registerInstance(new JoinTypeType()); 280 | $this->registerInstance(new SortingOrderType()); 281 | } 282 | 283 | /** 284 | * Throw an exception if the class name is not Doctrine entity. 285 | */ 286 | private function throwIfNotEntity(string $className): void 287 | { 288 | if (!$this->isEntity($className)) { 289 | throw new UnexpectedValueException('Given class name `' . $className . '` is not a Doctrine entity. Either register a custom GraphQL type for `' . $className . '` when instantiating `' . self::class . '`, or change the usage of that class to something else.'); 290 | } 291 | } 292 | 293 | public function createFilteredQueryBuilder(string $className, array $filter, array $sorting): QueryBuilder 294 | { 295 | return $this->filteredQueryBuilderFactory->create($className, $filter, $sorting); 296 | } 297 | 298 | public function loadType(string $typeName, string $namespace): ?Type 299 | { 300 | if ($this->has($typeName)) { 301 | return $this->get($typeName); 302 | } 303 | 304 | if (preg_match('~^(?.*)(?PartialInput)$~', $typeName, $m) 305 | || preg_match('~^(?.*)(?Input|PartialInput|Filter|Sorting|FilterGroupJoin|FilterGroupCondition|ID)$~', $typeName, $m) 306 | || preg_match('~^(?JoinOn)(?.*)$~', $typeName, $m) 307 | || preg_match('~^(?.*)$~', $typeName, $m)) { 308 | $shortName = $m['shortName']; 309 | $kind = $m['kind'] ?? ''; 310 | 311 | /** @var class-string $className */ 312 | $className = $namespace . '\\' . $shortName; 313 | 314 | if ($this->isEntity($className)) { 315 | return match ($kind) { 316 | 'Input' => $this->getViaFactory($className, $typeName, $this->inputTypeFactory), 317 | 'PartialInput' => $this->getViaFactory($className, $typeName, $this->partialInputTypeFactory), 318 | 'Filter' => $this->getViaFactory($className, $typeName, $this->filterTypeFactory), 319 | 'Sorting' => $this->getViaFactory($className, $typeName, $this->sortingTypeFactory), 320 | 'JoinOn' => $this->getViaFactory($className, $typeName, $this->joinOnTypeFactory), 321 | 'FilterGroupJoin' => $this->getViaFactory($className, $typeName, $this->filterGroupJoinTypeFactory), 322 | 'FilterGroupCondition' => $this->getViaFactory($className, $typeName, $this->filterGroupConditionTypeFactory), 323 | 'ID' => $this->getViaFactory($className, $typeName, $this->entityIDTypeFactory), 324 | '' => $this->getViaFactory($className, $typeName, $this->objectTypeFactory), 325 | default => throw new Exception("Unsupported kind of type `$kind` when trying to load type `$typeName`"), 326 | }; 327 | } 328 | } 329 | 330 | return null; 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /src/TypesInterface.php: -------------------------------------------------------------------------------- 1 | fn (string $name) => $types->loadType($name, 'Application\Model') ?? $types->loadType($name, 'OtherApplication\Model') 130 | * // ... 131 | * ]); 132 | * ``` 133 | * 134 | * While this method could technically replace of uses of dedicated `get*()` methods, we suggest to only use 135 | * `loadType` with the `typeLoader`. Because dedicated `get*()` methods are easier to use, and provide 136 | * stronger typing. 137 | * 138 | * @return null|(NamedType&Type) 139 | */ 140 | public function loadType(string $typeName, string $namespace): ?Type; 141 | } 142 | -------------------------------------------------------------------------------- /src/Utils.php: -------------------------------------------------------------------------------- 1 | name); 35 | } 36 | } 37 | --------------------------------------------------------------------------------