├── .github └── workflows │ ├── ci.yml │ └── static.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── composer.json ├── phpstan.neon ├── phpstan.tests.neon ├── phpunit.xml.dist ├── src ├── Annotation │ └── Preferred.php ├── Builder.php ├── Exception │ ├── InvalidTypeException.php │ ├── ParseException.php │ ├── RecursionException.php │ └── SchemaException.php ├── Metadata │ ├── AbstractPropertyMetadata.php │ ├── AbstractPropertyType.php │ ├── ClassMetadata.php │ ├── DateTimeOptions.php │ ├── ParameterMetadata.php │ ├── PropertyAccessor.php │ ├── PropertyMetadata.php │ ├── PropertyType.php │ ├── PropertyTypeArray.php │ ├── PropertyTypeClass.php │ ├── PropertyTypeDateTime.php │ ├── PropertyTypeIterable.php │ ├── PropertyTypePrimitive.php │ ├── PropertyTypeUnknown.php │ └── VersionRange.php ├── ModelParser │ ├── JMSParser.php │ ├── JMSParserLegacy.php │ ├── LiipMetadataAnnotationParser.php │ ├── ModelParserInterface.php │ ├── ParserContext.php │ ├── PhpDocParser.php │ ├── RawMetadata │ │ ├── PropertyCollection.php │ │ ├── PropertyVariationMetadata.php │ │ └── RawClassMetadata.php │ ├── ReflectionParser.php │ └── VisibilityAwarePropertyAccessGuesser.php ├── Parser.php ├── PropertyReducer.php ├── RawClassMetadataRegistry.php ├── RecursionChecker.php ├── RecursionContext.php ├── Reducer │ ├── GroupReducer.php │ ├── PreferredReducer.php │ ├── PropertyReducerInterface.php │ ├── TakeBestReducer.php │ └── VersionReducer.php └── TypeParser │ ├── JMSTypeParser.php │ └── PhpTypeParser.php └── tests ├── BuilderTest.php ├── Metadata ├── ClassMetadataTest.php ├── PropertyTypeIterableTest.php ├── PropertyTypeTest.php └── VersionRangeTest.php ├── ModelParser ├── Fixtures │ ├── IntersectionTypeDeclarationModel.php │ ├── TypeDeclarationModel.php │ └── UnionTypeDeclarationModel.php ├── JMSParserTest81.php ├── JMSParserTestCase.php ├── JMSParserTestLegacy.php ├── Model │ ├── AbstractModel.php │ ├── BaseModel.php │ ├── Nested.php │ ├── Recursion.php │ ├── ReflectionAbstractModel.php │ ├── ReflectionBaseModel.php │ └── WithImports.php ├── ParserContextTest.php ├── PhpDocParserTest.php ├── RawMetadata │ └── RawClassMetadataTest.php ├── ReflectionParserTest.php └── VisibilityAwarePropertyAccessGuesserTest.php ├── ParserTest.php ├── PropertyReducerTest.php ├── RecursionCheckerTest.php ├── RecursionContextTest.php ├── Reducer ├── GroupReducerTest.php ├── TakeBestReducerTest.php └── VersionReducerTest.php ├── TypeParser ├── JMSTypeParserTest.php └── PhpTypeParserTest.php └── bootstrap.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*.x' 7 | tags: 8 | - '[0-9].[0-9]+' 9 | pull_request: 10 | 11 | jobs: 12 | test: 13 | name: "PHPUnit" 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | php-version: ['7.4', '8.0', '8.1', '8.2'] 18 | include: 19 | - php-version: '7.4' 20 | composer-flags: '--prefer-stable --prefer-lowest' 21 | steps: 22 | - name: Check out code into the workspace 23 | uses: actions/checkout@v3 24 | - name: Setup PHP ${{ matrix.php-version }} 25 | uses: shivammathur/setup-php@v2 26 | with: 27 | php-version: ${{ matrix.php-version }} 28 | - name: Remove dev tools to not interfere with dependencies 29 | run: composer remove --dev friendsofphp/php-cs-fixer phpstan/phpstan-phpunit phpstan/phpstan 30 | - name: Composer cache 31 | uses: actions/cache@v3 32 | with: 33 | path: ${{ env.HOME }}/.composer/cache 34 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 35 | - name: Install dependencies 36 | run: composer update ${{ matrix.composer-flags }} --prefer-dist --no-interaction 37 | - name: Run tests 38 | run: composer phpunit 39 | -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | name: Static 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*.x' 7 | tags: 8 | - '[0-9].[0-9]+' 9 | pull_request: 10 | 11 | jobs: 12 | phpstan: 13 | name: "PHPStan" 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out code into the workspace 17 | uses: actions/checkout@v3 18 | - name: Setup PHP 8.2 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: 8.2 22 | - name: Composer cache 23 | uses: actions/cache@v3 24 | with: 25 | path: ${{ env.HOME }}/.composer/cache 26 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 27 | - name: Install dependencies 28 | run: composer update --prefer-dist --no-interaction 29 | - name: PHPStan 30 | run: composer phpstan-all 31 | 32 | cs: 33 | name: "CS Fixer" 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Check out code into the workspace 37 | uses: actions/checkout@v3 38 | - name: Setup PHP 8.2 39 | uses: shivammathur/setup-php@v2 40 | with: 41 | php-version: 8.2 42 | - name: Validate composer.json 43 | run: composer validate --strict --no-check-lock 44 | - name: Composer cache 45 | uses: actions/cache@v3 46 | with: 47 | path: ${{ env.HOME }}/.composer/cache 48 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 49 | - name: Install dependencies 50 | run: composer update --prefer-dist --no-interaction 51 | - name: CS Fixer 52 | run: composer cs-fixer 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Composer files 2 | /vendor 3 | /composer.lock 4 | 5 | ###> phpunit/phpunit ### 6 | /phpunit.xml 7 | /.phpunit.result.cache 8 | ###< phpunit/phpunit ### 9 | 10 | ###> friendsofphp/php-cs-fixer ### 11 | /.php-cs-fixer.php 12 | /.php-cs-fixer.cache 13 | ###< friendsofphp/php-cs-fixer ### 14 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setFinder( 8 | PhpCsFixer\Finder::create() 9 | ->in([ 10 | __DIR__, 11 | ]) 12 | ->notPath('src/ModelParser/JMSParserLegacy.php') 13 | ); 14 | 15 | $config 16 | ->setRiskyAllowed(true) 17 | ->setRules( 18 | [ 19 | '@PhpCsFixer' => true, 20 | '@PhpCsFixer:risky' => true, 21 | '@Symfony' => true, 22 | '@Symfony:risky' => true, 23 | 'array_syntax' => ['syntax' => 'short'], 24 | 'declare_strict_types' => true, 25 | 'list_syntax' => ['syntax' => 'short'], 26 | 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], 27 | 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'], 28 | 'native_function_invocation' => [ 29 | 'include' => ['@compiler_optimized'], 30 | ], 31 | 'no_superfluous_phpdoc_tags' => true, 32 | 'php_unit_dedicate_assert' => true, 33 | 'php_unit_expectation' => true, 34 | 'php_unit_mock' => true, 35 | 'php_unit_namespaced' => true, 36 | 'php_unit_no_expectation_annotation' => true, 37 | 'phpdoc_to_return_type' => true, 38 | 'static_lambda' => true, 39 | 'ternary_to_null_coalescing' => true, 40 | 'void_return' => true, 41 | 42 | // Don't mark tests as @internal 43 | 'php_unit_internal_class' => false, 44 | 45 | // Don't require @covers in tests 46 | 'php_unit_test_class_requires_covers' => false, 47 | 48 | // Don't require dots in phpdocs 49 | 'phpdoc_annotation_without_dot' => false, 50 | 'phpdoc_summary' => false, 51 | 52 | // Sometimes we need to do non-strict comparison 53 | 'strict_comparison' => false, 54 | 55 | // The convention with phpunit has been to use assertions with the object context. 56 | 'php_unit_test_case_static_method_calls' => false, 57 | 58 | // Not supported in PHP 7 59 | 'get_class_to_class_keyword' => false, 60 | 'modernize_strpos' => false, 61 | ] 62 | ) 63 | ; 64 | 65 | return $config; 66 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | # Version 1.x 4 | 5 | # 1.2.0 6 | 7 | * DateTimeOptions now features a list of deserialization formats instead of a single string one. Passing a string instead of an array to its `__construct`or is deprecated, and will be forbidden in the next version 8 | Similarly, `getDeserializeFormat(): ?string` is deprecated in favor of `getDeserializeFormats(): ?array` 9 | * Added `PropertyTypeIterable`, which generalizes `PropertyTypeArray` to allow merging Collection informations like one would with arrays, including between interfaces and concrete classes 10 | * Deprecated `PropertyTypeArray`, please prefer using `PropertyTypeIterable` instead 11 | * `PropertyTypeArray::isCollection()` and `PropertyTypeArray::getCollectionClass()` are deprecated, including in its child classes, in favor of `isTraversable()` and `getTraversableClass()` 12 | * Added a model parser `VisibilityAwarePropertyAccessGuesser` that tries to guess getter and setter methods for non-public properties. 13 | 14 | # 1.1.0 15 | 16 | * Drop support for PHP 7.2 and PHP 7.3 17 | * Support doctrine annotations `2.x` 18 | 19 | # 1.0.0 20 | 21 | No changes since 0.6.1. 22 | 23 | # 0.6.1 24 | 25 | * Do not ignore methods that have no phpdoc but do have a PHP 8.1 attribute to make them virtual properties. 26 | 27 | # 0.6.0 28 | 29 | * When running with PHP 8, process attributes in addition to the phpdoc annotations. 30 | * Support doctrine collections 31 | * Support `identical` property naming strategy 32 | * Add support for the `MaxDepth` annotation from JMS 33 | 34 | # 0.5.0 35 | 36 | * Support JMS Serializer `ReadOnlyProperty` in addition to `ReadOnly` to be compatible with serializer 3.14 and newer. 37 | * Support PHP 8.1 (which makes ReadOnly a reserved keyword) 38 | 39 | # 0.4.1 40 | 41 | * Allow installation with psr/log 2 and 3, to allow installation with Symfony 6 42 | 43 | # 0.4.0 44 | 45 | * Handle property type declarations in reflection parser. 46 | * [Bugfix] Upgrade array type with `@var Type[]` annotation 47 | * [Bugfix] When extending class redefines a property, use phpdoc from extending class rather than base class 48 | * [Bugfix] Use correct context for relative class names in inherited properties/methods 49 | 50 | # 0.3.0 51 | 52 | * Support PHP 8, drop support for PHP 7.1 53 | * Support JMS 3 and drop support for JMS 1 54 | * [BC Break] Only if you wrote your own PropertyMetadata class and overwrote `getCustomInformation`: That method now has the return type `:mixed` specified. 55 | 56 | # 0.2.1 57 | 58 | * [Bugfix] Look in parent classes and traits for imports 59 | 60 | # 0.2.0 61 | 62 | * [Feature] Support to track custom metadata 63 | 64 | # 0.1.0 65 | 66 | * Initial release 67 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 2 | 3 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 4 | 5 | Examples of unacceptable behavior by participants include: 6 | * The use of sexualized language or imagery 7 | * Personal attacks 8 | * Trolling or insulting/derogatory comments 9 | * Public or private harassment 10 | * Publishing other’s private information, such as physical or electronic addresses, without explicit permission 11 | * Other unethical or unprofessional conduct. 12 | 13 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 14 | 15 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 16 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 17 | 18 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org/) , version 1.2.0, available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | If you wish to contribute to Metadata-Parser, feel free to do so. 2 | 3 | It is generally a good idea to open an issue before you create a Pull-Request to avoid wasting your time. That way the maintainers can ensure that the changes you want to make, is something that the project would welcome. 4 | 5 | ## What to work on 6 | You can work on anything! All the current issues are listed on the GitHub issue tracker. If you don’t see the thing you want to work on there, please open a new issue. 7 | 8 | PR’s improving documentation and tests are always very welcome too. 9 | 10 | ## Pull-Request workflow 11 | Change the code in your own fork/branch. When you believe the code is ready to be applied, submit the PR. 12 | 13 | Once you have submitted the PR, people will look at it and provide feedback. 14 | 15 | If it cannot be applied in its current state, then a comment will be left and the PR will be closed. 16 | 17 | In general, only submit a PR if you believe it can be applied in its current state. If you need an exception to this rule please use “WIP: “ in front of your PR to indicate that it is work in progress. This could be beneficial for instance if you’re looking for feedback while your PR is still in progress. 18 | 19 | When you have time to address the comments left on your PR, please make the changes, push them to github and then re-open the pull request. 20 | 21 | If you do not have time to address the changes, that’s OK. Just leave a comment in the PR and either someone else can pick up the changes, or the PR will be closed. 22 | 23 | ## Coding Style & Decisions 24 | We write tests for our code, if you add code, please also add tests for that code. We do not demand specific code coverage, but we want tests where they make sense. 25 | 26 | In general this project uses the [Symfony coding standards](https://symfony.com/doc/current/contributing/co). 27 | 28 | We have our own standards when it comes to PHPDoc, when in doubt use these rules over Symfony’s. 29 | 30 | ### PHPDoc 31 | It’s a good idea to mention GitHub issue numbers when there are hardcoded special cases or other business decisions in the code. Always aim to explain the why behind the how; 32 | 33 | If there is something to explain about a class, use the class docblock and not the constructor. Constructors will rarely need explanation apart from parameter descriptions; 34 | 35 | Use the class docblock to explain both technical but also business WHY questions; 36 | 37 | Add the @var Type annotation on all properties of the class. The type is usually also clearly defined from the constructor and the assignment, but we find it more readable with the @var annotations; 38 | 39 | Do not use the @throws tag unless you have something very specific to say about it. We do not care to track exceptions flow through all the code. Disable the warning for this in your IDE if needed; 40 | 41 | Inline comments should be kept when they add value (for example reference GitHub issues, explain a regular expression, etc). But when the code is hard to read, consider refactoring by extracting code into private methods or separate classes. With good naming, such methods can often replace a code comment. 42 | 43 | When in doubt, add a comment. 44 | 45 | #### Parameter documentation 46 | Parameter documentation should explain the value range if there are restrictions further than the type. Use @param annotations if: 47 | 48 | * There is further information about the value range that is not obvious from the type. E.g. a string that can only be some specific constants, or @param int $priority From 0 to 10. The higher the number, the higher the priority. One special case is arrays, which can not be further declared in PHP; 49 | 50 | * Always specify the list element types of arrays with a docblock, e.g. @param string[] $list; 51 | 52 | * Only add documentation for parameters where there is something to add. "Incomplete" doc blocks are fine. If your IDE is complaining about this: Disable the warnings for docblocks. We have code sniffers to ensure our doc blocks do not contain wrong parameter names. 53 | 54 | #### Return type documentation 55 | Use the : Type syntax, resp : ?Type for Type|null. Do not use @return unless an array is returned, or when mixed types are returned. 56 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Metadata-Parser is the result of the work of many people who made the code, the documentation and anything around it better. 2 | 3 | Thank you for your work. 4 | 5 | These are the people who in any way contributed to the project (in no particular order): 6 | David Buchmann (dbu) 7 | Michelle Sanver (michellesanver) 8 | Rae Knowler (bellisk) 9 | Tobias Schultze (Tobion) 10 | Christian Riesen (ChristianRiesen) 11 | Martin Sanser (mjanser) 12 | Emanuele Panzeri (thePanz) 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Liip 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 | # Liip Metadata Parser 2 | 3 | **This project is Open Sourced based on work that we did initially as closed source at Liip, it may be lacking some documentation. If there is anything that you need or have questions about we would love to see you open an issue! :)** 4 | 5 | This is a parser for building model metadata from PHP classes. The metadata model can 6 | then be used to generate code or configuration. For example a serializer or 7 | ElasticSearch schema for types. 8 | 9 | The metadata is geared toward this use case. PHP level constructs that 10 | represent the same information are grouped together: Methods for virtual 11 | properties and fields that have the same serialized name, but are valid in 12 | different versions. 13 | 14 | This extensible parser can process PHP code and annotations or other metadata. 15 | You could write your own parsers, but this library comes with support for: 16 | 17 | * Reflection 18 | * PhpDoc 19 | * JMSSerializer annotations 20 | 21 | ## Contributing 22 | 23 | If you want to contribute to the project (awesome!!), please read the 24 | [Contributing Guidelines](https://github.com/liip/metadata-parser/blob/master/CONTRIBUTING.md) 25 | and adhere to our [Code Of Conduct](https://github.com/liip/metadata-parser/blob/master/CODE_OF_CONDUCT.md) 26 | 27 | ## Where do I go for help? 28 | If you need, open an issue. 29 | 30 | ## Setup 31 | ```php 32 | use Doctrine\Common\Annotations\AnnotationReader; 33 | use Liip\MetadataParser\Builder; 34 | use Liip\MetadataParser\Parser; 35 | use Liip\MetadataParser\RecursionChecker; 36 | use Liip\MetadataParser\ModelParser\JMSParser; 37 | use Liip\MetadataParser\ModelParser\LiipMetadataAnnotationParser; 38 | use Liip\MetadataParser\ModelParser\PhpDocParser; 39 | use Liip\MetadataParser\ModelParser\ReflectionParser; 40 | use Liip\MetadataParser\ModelParser\VisibilityAwarePropertyAccessGuesser; 41 | 42 | $parser = new Parser( 43 | new ReflectionParser(), 44 | new PhpDocParser(), 45 | new JMSParser(new AnnotationReader()), 46 | new VisibilityAwarePropertyAccessGuesser(), 47 | new LiipMetadataAnnotationParser(new AnnotationReader()), 48 | ); 49 | 50 | $recursionChecker = new RecursionChecker(new NullLogger()); 51 | 52 | $builder = new Builder($parser, $recursionChecker); 53 | ``` 54 | 55 | ## Usage 56 | 57 | The `Builder::build` method is the main entry point to get a `ClassMetadata` 58 | object. The builder accepts an array of `Reducer` to select which property to 59 | use when there are several options. A reducer can lead to drop a property if 60 | none of the variants is acceptable. Multiple options for a single field mainly 61 | come from the `@SerializedName` and `@VirtualProperty` annotations of 62 | JMSSerializer: 63 | 64 | * GroupReducer: Select the property based on whether it is in any of the 65 | specified groups; 66 | * VersionReducer: Select the property based on whether it is included in the 67 | specified version; 68 | * TakeBestReducer: Make sure that we end up with the property that has the same 69 | name as the serialized name, if we still have multiple options after the 70 | other reducers. 71 | 72 | ```php 73 | use Liip\MetadataParser\Reducer\GroupReducer; 74 | use Liip\MetadataParser\Reducer\PreferredReducer; 75 | use Liip\MetadataParser\Reducer\TakeBestReducer; 76 | use Liip\MetadataParser\Reducer\VersionReducer; 77 | 78 | $reducers = [ 79 | new VersionReducer('2'), 80 | new GroupReducer(['api', 'detail']), 81 | new PreferredReducer(), 82 | new TakeBestReducer(), 83 | ]; 84 | $metadata = $builder->build(MyClass::class, $reducers); 85 | ``` 86 | 87 | The `ClassMetadata` provides all information on a class. Properties have a 88 | `PropertyType` to tell what kind of property they are. Properties that hold 89 | another class, are of the type `PropertyTypeClass` that has the method 90 | `getClassMetadata()` to get the metadata of the nested class. This structure 91 | is validated to not contain any infinite recursion. 92 | 93 | ### Property naming strategy 94 | 95 | By default, property names will be translated from a camelCased to a lower and 96 | snake_cased name (e.g. `myProperty` becomes `my_property`). If you want to keep 97 | the property name as is, you can change the strategy to `identical` via the 98 | following code: 99 | 100 | ```php 101 | \Liip\MetadataParser\ModelParser\RawMetadata\PropertyCollection::useIdenticalNamingStrategy(); 102 | ``` 103 | 104 | ### Handling Edge Cases with @Preferred 105 | 106 | This library provides its own annotation in `Liip\MetadataParser\Annotation\Preferred` 107 | to specify which property to use in case there are several options. This can be 108 | useful for example when serializing models without specifying a version, when 109 | they use different virtual properties in different versions. 110 | 111 | ```php 112 | use JMS\Serializer\Annotation as JMS; 113 | use Liip\MetadataParser\Annotation as Liip; 114 | 115 | class Product 116 | { 117 | /** 118 | * @JMS\Since("2") 119 | * @JMS\Type("string") 120 | */ 121 | public $name; 122 | 123 | /** 124 | * @JMS\Until("1") 125 | * @JMS\SerializedName("name") 126 | * @JMS\Type("string") 127 | * @Liip\Preferred 128 | */ 129 | public $legacyName; 130 | } 131 | ``` 132 | 133 | ### Expected Recursion: Working with Flawed Models 134 | 135 | #### JMS annotation 136 | 137 | If you are using JMS annotations, you can add the `MaxDepth` annotation to 138 | properties that might be recursive. 139 | 140 | The following example will tell the metadata parser that the recursion is 141 | expected up to a maximum depth of `3`. 142 | 143 | ```php 144 | use JMS\Serializer\Annotation as JMS; 145 | 146 | class RecursionModel 147 | { 148 | /** 149 | * @JMS\MaxDepth(3) 150 | * @JMS\Type("RecursionModel") 151 | */ 152 | public $recursion; 153 | } 154 | ``` 155 | 156 | #### Pure PHP 157 | 158 | The RecursionChecker accepts a second parameter to specify places where to 159 | break recursion. This is useful if your model tree looks like it has recursions 160 | but actually does not have them. JMSSerializer always acts on the actual data 161 | and therefore does not notice a recursion as long as it is not infinite. 162 | 163 | For example, lets say you have a `Product` that has a field `variants` which is 164 | again list of `Product`. Those variant products use the same class and 165 | therefore have the `variants` field. However, in real data a variant never 166 | contains further variants. To avoid a recursion exception for this example, you 167 | would specify: 168 | 169 | ```php 170 | $expectedRecursions = [ 171 | ['variants', 'variants'], 172 | ]; 173 | $recursionChecker = new RecursionChecker(new NullLogger(), $expectedRecursions); 174 | ``` 175 | 176 | With this configuration, the `ClassMetadata` found in the property type for the 177 | variants property of the final model will have no field `variants`, so that 178 | code working on the metadata does not need to worry about infinite recursion. 179 | 180 | ## Extending the metadata parser 181 | 182 | This library comes with a couple of parsers, but you can write your own to 183 | handle custom information specific to your project. Use the 184 | `PropertyVariationMetadata::setCustomInformation` method to add custom data, 185 | and use `PropertyMetadata::getCustomInformation` to read it in your metadata 186 | consumers. 187 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "liip/metadata-parser", 3 | "description": "Parser for metadata", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Liip AG", 9 | "homepage": "http://www.liip.ch" 10 | } 11 | ], 12 | "support": { 13 | "source": "https://github.com/liip/metadata-parser", 14 | "issues": "https://github.com/liip/metadata-parser/issues" 15 | }, 16 | "require": { 17 | "php": "^7.4 || ^8.0", 18 | "ext-json": "*", 19 | "doctrine/annotations": "^1.13 || ^2.0.1", 20 | "psr/log": "^1|^2|^3" 21 | }, 22 | "require-dev": { 23 | "doctrine/collections": "^1.6", 24 | "friendsofphp/php-cs-fixer": "v3.17.0", 25 | "jms/serializer": "^2.3 || ^3", 26 | "phpstan/phpstan": "^1.0", 27 | "phpstan/phpstan-phpunit": "^1.0", 28 | "phpunit/phpunit": "^8.5.15 || ^9.5" 29 | }, 30 | "suggest": { 31 | "jms/serializer": "^2.3 || ^3" 32 | }, 33 | "conflict": { 34 | "doctrine/annotations": "< 1.11", 35 | "jms/serializer": "< 2.3" 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "Liip\\MetadataParser\\": "src/" 40 | } 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "Tests\\Liip\\MetadataParser\\": "tests/" 45 | } 46 | }, 47 | "scripts": { 48 | "fix-cs": "vendor/bin/php-cs-fixer fix -v", 49 | "cs-fixer": "vendor/bin/php-cs-fixer fix --dry-run --diff -v", 50 | "phpstan": "vendor/bin/phpstan analyse", 51 | "phpstan-tests": "vendor/bin/phpstan analyse -c phpstan.tests.neon", 52 | "phpstan-all": [ 53 | "@phpstan", 54 | "@phpstan-tests" 55 | ], 56 | "phpunit": "vendor/bin/phpunit", 57 | "ci": [ 58 | "@cs-fixer", 59 | "@phpstan-all", 60 | "@phpunit" 61 | ] 62 | }, 63 | "config": { 64 | "sort-packages": true 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 5 3 | dynamicConstantNames: 4 | - PHP_VERSION_ID 5 | paths: 6 | - src/ 7 | excludePaths: 8 | - src/ModelParser/JMSParserLegacy.php 9 | -------------------------------------------------------------------------------- /phpstan.tests.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 1 3 | paths: 4 | - tests/ 5 | excludePaths: 6 | - tests/ModelParser/JMSParserTestLegacy.php 7 | # looks like phpstan php parser has not implemented union types yet 8 | - tests/ModelParser/Fixtures/IntersectionTypeDeclarationModel.php 9 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests 10 | 11 | 12 | 13 | 14 | 15 | src 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Annotation/Preferred.php: -------------------------------------------------------------------------------- 1 | metadata. It should then not 19 | * set the metadata on the property types. This would allow to work with 20 | * general model graphs that may include recursion. 21 | */ 22 | final class Builder 23 | { 24 | /** 25 | * @var Parser 26 | */ 27 | private $parser; 28 | 29 | /** 30 | * @var RecursionChecker 31 | */ 32 | private $recursionChecker; 33 | 34 | public function __construct(Parser $parser, RecursionChecker $recursionChecker) 35 | { 36 | $this->parser = $parser; 37 | $this->recursionChecker = $recursionChecker; 38 | } 39 | 40 | /** 41 | * Build the tree for the specified class. 42 | * 43 | * This tree is guaranteed to be a tree and can not be a graph with recursion. 44 | * 45 | * @param PropertyReducerInterface[] $reducers 46 | * 47 | * @throws ParseException 48 | */ 49 | public function build(string $className, array $reducers = []): ClassMetadata 50 | { 51 | $rawClassMetadataList = $this->parser->parse($className); 52 | 53 | /** @var ClassMetadata[] $classMetadataList */ 54 | $classMetadataList = []; 55 | foreach ($rawClassMetadataList as $rawClassMetadata) { 56 | $classMetadataList[$rawClassMetadata->getClassName()] = PropertyReducer::reduce($rawClassMetadata, $reducers); 57 | } 58 | 59 | foreach ($classMetadataList as $classMetadata) { 60 | foreach ($classMetadata->getProperties() as $property) { 61 | try { 62 | $this->setTypeClassMetadata($property->getType(), $classMetadataList); 63 | } catch (\UnexpectedValueException $e) { 64 | throw ParseException::classNotParsed($e->getMessage(), (string) $classMetadata, (string) $property); 65 | } 66 | } 67 | } 68 | 69 | return $this->recursionChecker->check($classMetadataList[$className]); 70 | } 71 | 72 | /** 73 | * @param ClassMetadata[] $classMetadataList 74 | * 75 | * @throws \UnexpectedValueException if the class is not found 76 | */ 77 | private function setTypeClassMetadata(PropertyType $type, array $classMetadataList): void 78 | { 79 | if ($type instanceof PropertyTypeClass) { 80 | if (!\array_key_exists($type->getClassName(), $classMetadataList)) { 81 | throw new \UnexpectedValueException($type->getClassName()); 82 | } 83 | $type->setClassMetadata($classMetadataList[$type->getClassName()]); 84 | 85 | return; 86 | } 87 | 88 | if ($type instanceof PropertyTypeIterable) { 89 | $this->setTypeClassMetadata($type->getLeafType(), $classMetadataList); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Exception/InvalidTypeException.php: -------------------------------------------------------------------------------- 1 | getCode() : 0, 16 | $previousException 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Exception/ParseException.php: -------------------------------------------------------------------------------- 1 | getCode(), 27 | $previousException 28 | ); 29 | } 30 | 31 | public static function classError(string $className, \Exception $previousException): self 32 | { 33 | return new self( 34 | sprintf(self::CLASS_ERROR, $className, $previousException->getMessage()), 35 | $previousException->getCode(), 36 | $previousException 37 | ); 38 | } 39 | 40 | public static function propertyError(string $className, string $propertyName, \Exception $previousException): self 41 | { 42 | return new self( 43 | sprintf(self::PROPERTY_ERROR, $className, $propertyName, $previousException->getMessage()), 44 | $previousException->getCode(), 45 | $previousException 46 | ); 47 | } 48 | 49 | public static function propertyTypeError(string $className, string $propertyName, \Exception $previousException): self 50 | { 51 | return new self( 52 | sprintf(self::PROPERTY_TYPE_ERROR, $className, $propertyName, $previousException->getMessage()), 53 | $previousException->getCode(), 54 | $previousException 55 | ); 56 | } 57 | 58 | public static function propertyTypeNameNull(string $className, string $propertyName): self 59 | { 60 | return new self(sprintf(self::PROPERTY_TYPE_NAME_NULL, $className, $propertyName)); 61 | } 62 | 63 | public static function propertyTypeConflict(string $className, string $propertyName, string $typeA, string $typeB, \Exception $previousException): self 64 | { 65 | return new self( 66 | sprintf(self::PROPERTY_TYPE_CONFLICT, $className, $propertyName, $typeA, $typeB), 67 | $previousException->getCode(), 68 | $previousException 69 | ); 70 | } 71 | 72 | public static function unsupportedClassAnnotation(string $className, string $annotation): self 73 | { 74 | return new self(sprintf(self::UNSUPPORTED_CLASS_ANNOTATION, $className, $annotation)); 75 | } 76 | 77 | public static function unsupportedPropertyAnnotation(string $className, string $propertyName, string $annotation): self 78 | { 79 | return new self(sprintf(self::UNSUPPORTED_PROPERTY_ANNOTATION, $className, $propertyName, $annotation)); 80 | } 81 | 82 | public static function nonPublicMethod(string $className, string $methodName): self 83 | { 84 | return new self(sprintf(self::NON_PUBLIC_METHOD, $className, $methodName)); 85 | } 86 | 87 | public static function propertyAlreadyExists(string $propertyName, string $className): self 88 | { 89 | return new self(sprintf(self::PROPERTY_ALREADY_EXISTS, $propertyName, $className)); 90 | } 91 | 92 | public static function classNotParsed(string $notFoundClassName, string $className, string $propertyName): self 93 | { 94 | return new self(sprintf(self::CLASS_NOT_PARSED, $notFoundClassName, $className, $propertyName)); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Exception/RecursionException.php: -------------------------------------------------------------------------------- 1 | name = $name; 60 | $this->setReadOnly($readOnly); 61 | $this->setPublic($public); 62 | $this->accessor = PropertyAccessor::none(); 63 | $this->versionRange = VersionRange::all(); 64 | } 65 | 66 | public function __toString(): string 67 | { 68 | return $this->name; 69 | } 70 | 71 | public function getName(): string 72 | { 73 | return $this->name; 74 | } 75 | 76 | public function isReadOnly(): bool 77 | { 78 | return $this->readOnly; 79 | } 80 | 81 | public function isPublic(): bool 82 | { 83 | return $this->public; 84 | } 85 | 86 | /** 87 | * @return string[] 88 | */ 89 | public function getGroups(): array 90 | { 91 | return $this->groups; 92 | } 93 | 94 | public function getAccessor(): PropertyAccessor 95 | { 96 | return $this->accessor; 97 | } 98 | 99 | public function getVersionRange(): VersionRange 100 | { 101 | return $this->versionRange; 102 | } 103 | 104 | public function hasCustomInformation(string $key): bool 105 | { 106 | return \array_key_exists($key, $this->customInformation); 107 | } 108 | 109 | /** 110 | * Get the value stored as custom information, if it exists. 111 | * 112 | * @return mixed The information in whatever format it has been set 113 | * 114 | * @throws \InvalidArgumentException if no such custom value is available for this property 115 | */ 116 | public function getCustomInformation(string $key): mixed 117 | { 118 | if (!\array_key_exists($key, $this->customInformation)) { 119 | throw new \InvalidArgumentException(sprintf('Property %s has no custom information %s', $this->name, $key)); 120 | } 121 | 122 | return $this->customInformation[$key]; 123 | } 124 | 125 | /** 126 | * Hashmap of custom information with the key as index and the information as value. 127 | * 128 | * @return mixed[] 129 | */ 130 | public function getAllCustomInformation(): array 131 | { 132 | return $this->customInformation; 133 | } 134 | 135 | public function jsonSerialize(): array 136 | { 137 | $data = [ 138 | 'name' => $this->name, 139 | 'is_public' => $this->public, 140 | 'is_read_only' => $this->readOnly, 141 | 'max_depth' => $this->maxDepth, 142 | ]; 143 | 144 | if (\count($this->groups) > 0) { 145 | $data['groups'] = $this->groups; 146 | } 147 | if ($this->accessor->isDefined()) { 148 | $data['accessor'] = $this->accessor; 149 | } 150 | if ($this->versionRange->isDefined()) { 151 | $data['version'] = $this->versionRange; 152 | } 153 | if (\count($this->customInformation)) { 154 | $data['custom_information'] = array_map( 155 | static function ($information) { 156 | if (!\is_object($information) || $information instanceof \JsonSerializable) { 157 | return $information; 158 | } 159 | 160 | return '[non JsonSerializable custom information]'; 161 | }, 162 | $this->customInformation 163 | ); 164 | } 165 | 166 | return $data; 167 | } 168 | 169 | abstract public function getType(): PropertyType; 170 | 171 | protected function setVersionRange(VersionRange $version): void 172 | { 173 | $this->versionRange = $version; 174 | } 175 | 176 | protected function setAccessor(PropertyAccessor $accessor): void 177 | { 178 | $this->accessor = $accessor; 179 | } 180 | 181 | /** 182 | * @param string[] $groups 183 | */ 184 | protected function setGroups(array $groups): void 185 | { 186 | $this->groups = $groups; 187 | } 188 | 189 | protected function setReadOnly(bool $readOnly): void 190 | { 191 | $this->readOnly = $readOnly; 192 | } 193 | 194 | protected function setPublic(bool $public): void 195 | { 196 | $this->public = $public; 197 | } 198 | 199 | protected function setCustomInformation(string $key, $value): void 200 | { 201 | $this->customInformation[$key] = $value; 202 | } 203 | 204 | protected function getMaxDepth(): ?int 205 | { 206 | return $this->maxDepth; 207 | } 208 | 209 | protected function setMaxDepth(?int $maxDepth): void 210 | { 211 | $this->maxDepth = $maxDepth; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/Metadata/AbstractPropertyType.php: -------------------------------------------------------------------------------- 1 | nullable = $nullable; 22 | } 23 | 24 | public function __toString(): string 25 | { 26 | return $this->isNullable() ? '|null' : ''; 27 | } 28 | 29 | public function isNullable(): bool 30 | { 31 | return $this->nullable; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Metadata/ClassMetadata.php: -------------------------------------------------------------------------------- 1 | className = $className; 53 | $this->constructorParameters = $constructorParameters; 54 | $this->postDeserializeMethods = $postDeserializeMethods; 55 | 56 | foreach ($properties as $property) { 57 | $this->addProperty($property); 58 | } 59 | } 60 | 61 | public function __toString(): string 62 | { 63 | return $this->className; 64 | } 65 | 66 | /** 67 | * @param PropertyMetadata[] $properties 68 | */ 69 | public static function fromRawClassMetadata(RawClassMetadata $rawClassMetadata, array $properties): self 70 | { 71 | return new self( 72 | $rawClassMetadata->getClassName(), 73 | $properties, 74 | $rawClassMetadata->getConstructorParameters(), 75 | $rawClassMetadata->getPostDeserializeMethods() 76 | ); 77 | } 78 | 79 | public function getClassName(): string 80 | { 81 | return $this->className; 82 | } 83 | 84 | /** 85 | * @return PropertyMetadata[] 86 | */ 87 | public function getProperties(): array 88 | { 89 | return $this->properties; 90 | } 91 | 92 | /** 93 | * @return string[] 94 | */ 95 | public function getPostDeserializeMethods(): array 96 | { 97 | return $this->postDeserializeMethods; 98 | } 99 | 100 | /** 101 | * @return ParameterMetadata[] 102 | */ 103 | public function getConstructorParameters(): array 104 | { 105 | return $this->constructorParameters; 106 | } 107 | 108 | public function hasConstructorParameter(string $name): bool 109 | { 110 | foreach ($this->constructorParameters as $parameter) { 111 | if ($parameter->getName() === $name) { 112 | return true; 113 | } 114 | } 115 | 116 | return false; 117 | } 118 | 119 | public function getConstructorParameter(string $name): ParameterMetadata 120 | { 121 | foreach ($this->constructorParameters as $parameter) { 122 | if ($parameter->getName() === $name) { 123 | return $parameter; 124 | } 125 | } 126 | 127 | throw new \InvalidArgumentException(sprintf('Class %s has no constructor parameter called "%s"', $this->className, $name)); 128 | } 129 | 130 | /** 131 | * Returns a copy of the class metadata with the specified properties removed. 132 | * 133 | * This can be used for expected recursions to remove the affected properties. 134 | * 135 | * @param string[] $propertyNames 136 | */ 137 | public function withoutProperties(array $propertyNames): self 138 | { 139 | $properties = array_values(array_filter( 140 | $this->properties, 141 | static function (PropertyMetadata $property) use ($propertyNames): bool { 142 | return !\in_array($property->getName(), $propertyNames, true); 143 | } 144 | )); 145 | 146 | return new self( 147 | $this->className, 148 | $properties, 149 | $this->constructorParameters, 150 | $this->postDeserializeMethods 151 | ); 152 | } 153 | 154 | public function jsonSerialize(): array 155 | { 156 | return array_filter([ 157 | 'class_name' => $this->className, 158 | 'properties' => $this->properties, 159 | 'post_deserialize_method' => $this->postDeserializeMethods, 160 | 'constructor_parameters' => $this->constructorParameters, 161 | ]); 162 | } 163 | 164 | /** 165 | * @throws ParseException if the property already exists 166 | */ 167 | private function addProperty(PropertyMetadata $property): void 168 | { 169 | foreach ($this->properties as $prop) { 170 | if ($prop->getSerializedName() === $property->getSerializedName()) { 171 | throw ParseException::propertyAlreadyExists((string) $property, (string) $this); 172 | } 173 | } 174 | 175 | $this->properties[] = $property; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/Metadata/DateTimeOptions.php: -------------------------------------------------------------------------------- 1 | format = $format; 37 | $this->zone = $zone; 38 | $this->deserializeFormats = \is_string($deserializeFormats) ? [$deserializeFormats] : $deserializeFormats; 39 | } 40 | 41 | public function getFormat(): ?string 42 | { 43 | return $this->format; 44 | } 45 | 46 | public function getZone(): ?string 47 | { 48 | return $this->zone; 49 | } 50 | 51 | /** 52 | * @deprecated Please use {@see getDeserializeFormats} 53 | */ 54 | public function getDeserializeFormat(): ?string 55 | { 56 | foreach ($this->deserializeFormats ?? [] as $format) { 57 | return $format; 58 | } 59 | 60 | return null; 61 | } 62 | 63 | public function getDeserializeFormats(): ?array 64 | { 65 | return $this->deserializeFormats; 66 | } 67 | 68 | public function jsonSerialize(): array 69 | { 70 | return array_filter([ 71 | 'format' => $this->format, 72 | 'zone' => $this->zone, 73 | 'deserialize_format' => $this->getDeserializeFormat(), 74 | 'deserialize_formats' => $this->deserializeFormats, 75 | ]); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Metadata/ParameterMetadata.php: -------------------------------------------------------------------------------- 1 | name = $name; 27 | $this->required = $required; 28 | $this->defaultValue = $defaultValue; 29 | } 30 | 31 | public function __toString(): string 32 | { 33 | return $this->name; 34 | } 35 | 36 | public static function fromReflection(\ReflectionParameter $reflParameter): self 37 | { 38 | if ($reflParameter->isOptional()) { 39 | return new self($reflParameter->getName(), false, $reflParameter->getDefaultValue()); 40 | } 41 | 42 | return new self($reflParameter->getName(), true); 43 | } 44 | 45 | public function getName(): string 46 | { 47 | return $this->name; 48 | } 49 | 50 | public function isRequired(): bool 51 | { 52 | return $this->required; 53 | } 54 | 55 | /** 56 | * @throws \BadMethodCallException if the parameter is required and therefore has no default value 57 | */ 58 | public function getDefaultValue() 59 | { 60 | if ($this->required) { 61 | throw new \BadMethodCallException(sprintf('Parameter %s is required and therefore has no default value', (string) $this)); 62 | } 63 | 64 | return $this->defaultValue; 65 | } 66 | 67 | public function jsonSerialize(): array 68 | { 69 | return [ 70 | 'name' => $this->name, 71 | 'required' => $this->required, 72 | 'default_value' => $this->defaultValue, 73 | ]; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Metadata/PropertyAccessor.php: -------------------------------------------------------------------------------- 1 | getterMethod = $getterMethod; 16 | $this->setterMethod = $setterMethod; 17 | } 18 | 19 | public static function none(): self 20 | { 21 | return new self(null, null); 22 | } 23 | 24 | public function isDefined(): bool 25 | { 26 | return $this->hasGetterMethod() || $this->hasSetterMethod(); 27 | } 28 | 29 | public function hasGetterMethod(): bool 30 | { 31 | return null !== $this->getterMethod; 32 | } 33 | 34 | public function getGetterMethod(): ?string 35 | { 36 | return $this->getterMethod; 37 | } 38 | 39 | public function hasSetterMethod(): bool 40 | { 41 | return null !== $this->setterMethod; 42 | } 43 | 44 | public function getSetterMethod(): ?string 45 | { 46 | return $this->setterMethod; 47 | } 48 | 49 | public function jsonSerialize(): array 50 | { 51 | return array_filter([ 52 | 'getter_method' => $this->getterMethod, 53 | 'setter_method' => $this->setterMethod, 54 | ]); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Metadata/PropertyMetadata.php: -------------------------------------------------------------------------------- 1 | serializedName = $serializedName; 39 | $this->type = $type ?: new PropertyTypeUnknown(true); 40 | $this->setVersionRange($versionRange ?: new VersionRange(null, null)); 41 | $this->setGroups($groups); 42 | if ($accessor) { 43 | $this->setAccessor($accessor); 44 | } 45 | foreach ($customInformation as $key => $value) { 46 | $this->setCustomInformation((string) $key, $value); 47 | } 48 | 49 | $this->setMaxDepth($maxDepth); 50 | } 51 | 52 | public function __toString(): string 53 | { 54 | return $this->serializedName; 55 | } 56 | 57 | public static function fromRawProperty(string $serializedName, PropertyVariationMetadata $property): self 58 | { 59 | return new self( 60 | $serializedName, 61 | $property->getName(), 62 | $property->getType(), 63 | $property->isReadOnly(), 64 | $property->isPublic(), 65 | $property->getVersionRange(), 66 | $property->getGroups(), 67 | $property->getAccessor(), 68 | $property->getAllCustomInformation(), 69 | $property->getMaxDepth() 70 | ); 71 | } 72 | 73 | public function getType(): PropertyType 74 | { 75 | return $this->type; 76 | } 77 | 78 | public function getSerializedName(): string 79 | { 80 | return $this->serializedName; 81 | } 82 | 83 | public function getMaxDepth(): ?int 84 | { 85 | return parent::getMaxDepth(); 86 | } 87 | 88 | public function jsonSerialize(): array 89 | { 90 | return array_merge( 91 | [ 92 | 'serialized_name' => $this->serializedName, 93 | ], 94 | parent::jsonSerialize(), 95 | [ 96 | 'type' => (string) $this->type, 97 | ] 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Metadata/PropertyType.php: -------------------------------------------------------------------------------- 1 | subType = $subType; 34 | $this->hashmap = $hashmap; 35 | $this->isCollection = $isCollection; 36 | } 37 | 38 | public function __toString(): string 39 | { 40 | if ($this->subType instanceof PropertyTypeUnknown) { 41 | return 'array'.($this->isCollection ? '|\\'.Collection::class : ''); 42 | } 43 | 44 | $array = $this->isHashmap() ? '[string]' : '[]'; 45 | if ($this->isCollection) { 46 | $collectionType = $this->isHashmap() ? ', string' : ''; 47 | $array .= sprintf('|\\%s<%s%s>', Collection::class, $this->subType, $collectionType); 48 | } 49 | 50 | return ((string) $this->subType).$array.parent::__toString(); 51 | } 52 | 53 | public function isHashmap(): bool 54 | { 55 | return $this->hashmap; 56 | } 57 | 58 | /** 59 | * Returns the type of the next level, which could be an array or hashmap or another type. 60 | */ 61 | public function getSubType(): PropertyType 62 | { 63 | return $this->subType; 64 | } 65 | 66 | /** 67 | * @deprecated Please prefer using {@link isTraversable} 68 | */ 69 | public function isCollection(): bool 70 | { 71 | return $this->isCollection; 72 | } 73 | 74 | /** 75 | * @deprecated Please prefer using {@link getTraversableClass} 76 | * 77 | * @return class-string|null 78 | */ 79 | public function getCollectionClass(): ?string 80 | { 81 | return $this->isCollection() ? Collection::class : null; 82 | } 83 | 84 | public function isTraversable(): bool 85 | { 86 | return $this->isCollection; 87 | } 88 | 89 | /** 90 | * @return class-string<\Traversable> 91 | */ 92 | public function getTraversableClass(): string 93 | { 94 | if (!$this->isTraversable()) { 95 | throw new \UnexpectedValueException("Iterable type '{$this}' is not traversable."); 96 | } 97 | 98 | return \Traversable::class; 99 | } 100 | 101 | /** 102 | * Goes down the type until it is not an array or hashmap anymore. 103 | */ 104 | public function getLeafType(): PropertyType 105 | { 106 | $type = $this->getSubType(); 107 | while ($type instanceof self) { 108 | $type = $type->getSubType(); 109 | } 110 | 111 | return $type; 112 | } 113 | 114 | public function merge(PropertyType $other): PropertyType 115 | { 116 | $nullable = $this->isNullable() && $other->isNullable(); 117 | 118 | if ($other instanceof PropertyTypeUnknown) { 119 | return new self($this->subType, $this->isHashmap(), $nullable); 120 | } 121 | if ($this->isCollection() && (($other instanceof PropertyTypeClass) && is_a($other->getClassName(), Collection::class, true))) { 122 | return new self($this->getSubType(), $this->isHashmap(), $nullable, true); 123 | } 124 | if (!$other instanceof self) { 125 | throw new \UnexpectedValueException(sprintf('Can\'t merge type %s with %s, they must be the same or unknown', self::class, \get_class($other))); 126 | } 127 | 128 | /* 129 | * We allow converting array to hashmap (but not the other way round). 130 | * 131 | * PHPDoc has no clear definition for hashmaps with string indexes, but JMS Serializer annotations do. 132 | */ 133 | if ($this->isHashmap() && !$other->isHashmap()) { 134 | throw new \UnexpectedValueException(sprintf('Can\'t merge type %s with %s, can\'t change hashmap into plain array', self::class, \get_class($other))); 135 | } 136 | 137 | $hashmap = $this->isHashmap() || $other->isHashmap(); 138 | $isCollection = $this->isCollection || $other->isCollection; 139 | if ($other->getSubType() instanceof PropertyTypeUnknown) { 140 | return new self($this->getSubType(), $hashmap, $nullable, $isCollection); 141 | } 142 | if ($this->getSubType() instanceof PropertyTypeUnknown) { 143 | return new self($other->getSubType(), $hashmap, $nullable, $isCollection); 144 | } 145 | 146 | return new self($this->getSubType()->merge($other->getSubType()), $hashmap, $nullable, $isCollection); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Metadata/PropertyTypeClass.php: -------------------------------------------------------------------------------- 1 | className = $className; 33 | } 34 | 35 | public function __toString(): string 36 | { 37 | return $this->className.parent::__toString(); 38 | } 39 | 40 | public function getClassName(): string 41 | { 42 | return $this->className; 43 | } 44 | 45 | public function getClassMetadata(): ClassMetadata 46 | { 47 | if (null === $this->classMetadata) { 48 | throw new \BadMethodCallException('Internal error, custom class property type is missing the metadata. Looks like the schema builder didn\'t set it, which is a bug'); 49 | } 50 | 51 | return $this->classMetadata; 52 | } 53 | 54 | /** 55 | * This method is only to be used by the parsing process and is required to avoid a chicken-and-egg problem. 56 | * 57 | * @internal 58 | */ 59 | public function setClassMetadata(ClassMetadata $classMetadata): void 60 | { 61 | $this->classMetadata = $classMetadata; 62 | } 63 | 64 | public function merge(PropertyType $other): PropertyType 65 | { 66 | $nullable = $this->isNullable() && $other->isNullable(); 67 | 68 | if ($other instanceof PropertyTypeUnknown) { 69 | return new self($this->className, $nullable); 70 | } 71 | if (is_a($this->getClassName(), Collection::class, true) && (($other instanceof PropertyTypeIterable) && $other->isTraversable())) { 72 | return $other->merge($this); 73 | } 74 | if (is_a($this->getClassName(), \DateTimeInterface::class, true) && ($other instanceof PropertyTypeDateTime)) { 75 | return $other->merge($this); 76 | } 77 | if (!$other instanceof self) { 78 | throw new \UnexpectedValueException(sprintf('Can\'t merge type %s with %s, they must be the same or unknown', self::class, \get_class($other))); 79 | } 80 | if ($this->getClassName() !== $other->getClassName()) { 81 | throw new \UnexpectedValueException(sprintf('Can\'t merge type %s with %s, they must be equal', self::class, \get_class($other))); 82 | } 83 | 84 | return new self($this->className, $nullable); 85 | } 86 | 87 | public static function isTypeCustomClass(string $typeName): bool 88 | { 89 | return !PropertyTypePrimitive::isTypePrimitive($typeName) 90 | && !PropertyTypeDateTime::isTypeDateTime($typeName); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Metadata/PropertyTypeDateTime.php: -------------------------------------------------------------------------------- 1 | immutable = $immutable; 28 | $this->dateTimeOptions = $dateTimeOptions; 29 | } 30 | 31 | public function __toString(): string 32 | { 33 | $class = $this->immutable ? \DateTimeImmutable::class : \DateTime::class; 34 | 35 | return $class.parent::__toString(); 36 | } 37 | 38 | public function isImmutable(): bool 39 | { 40 | return $this->immutable; 41 | } 42 | 43 | public function getFormat(): ?string 44 | { 45 | if ($this->dateTimeOptions) { 46 | return $this->dateTimeOptions->getFormat(); 47 | } 48 | 49 | return null; 50 | } 51 | 52 | public function getZone(): ?string 53 | { 54 | if ($this->dateTimeOptions) { 55 | return $this->dateTimeOptions->getZone(); 56 | } 57 | 58 | return null; 59 | } 60 | 61 | /** 62 | * @deprecated Please prefer {@link getDeserializeFormats} 63 | */ 64 | public function getDeserializeFormat(): ?string 65 | { 66 | if ($this->dateTimeOptions) { 67 | return $this->dateTimeOptions->getDeserializeFormat(); 68 | } 69 | 70 | return null; 71 | } 72 | 73 | public function getDeserializeFormats(): ?array 74 | { 75 | if ($this->dateTimeOptions) { 76 | return $this->dateTimeOptions->getDeserializeFormats(); 77 | } 78 | 79 | return null; 80 | } 81 | 82 | public function merge(PropertyType $other): PropertyType 83 | { 84 | $nullable = $this->isNullable() && $other->isNullable(); 85 | 86 | if ($other instanceof PropertyTypeUnknown) { 87 | return new self($this->immutable, $nullable, $this->dateTimeOptions); 88 | } 89 | if (($other instanceof PropertyTypeClass) && is_a($other->getClassName(), \DateTimeInterface::class, true)) { 90 | if (is_a($other->getClassName(), \DateTimeImmutable::class, true)) { 91 | return new self(true, $nullable, $this->dateTimeOptions); 92 | } 93 | if (is_a($other->getClassName(), \DateTime::class, true) || (\DateTimeInterface::class === $other->getClassName())) { 94 | return new self(false, $nullable, $this->dateTimeOptions); 95 | } 96 | 97 | throw new \UnexpectedValueException("Can't merge type '{$this}' with '{$other}', they must be the same or unknown"); 98 | } 99 | if (!$other instanceof self) { 100 | throw new \UnexpectedValueException("Can't merge type '{$this}' with '{$other}', they must be the same or unknown"); 101 | } 102 | if ($this->isImmutable() !== $other->isImmutable()) { 103 | throw new \UnexpectedValueException("Can't merge type '{$this}' with '{$other}', they must be equal"); 104 | } 105 | 106 | $options = $this->dateTimeOptions ?: $other->dateTimeOptions; 107 | 108 | return new self($this->immutable, $nullable, $options); 109 | } 110 | 111 | public static function fromDateTimeClass(string $className, bool $nullable, DateTimeOptions $dateTimeOptions = null): self 112 | { 113 | if (!self::isTypeDateTime($className)) { 114 | throw new \UnexpectedValueException(sprintf('Given type "%s" is not date time class or interface', $className)); 115 | } 116 | 117 | return new self(\DateTimeImmutable::class === $className, $nullable, $dateTimeOptions); 118 | } 119 | 120 | public static function isTypeDateTime(string $typeName): bool 121 | { 122 | return \in_array($typeName, self::DATE_TIME_TYPES, true); 123 | } 124 | 125 | /** 126 | * Find the most derived class that doesn't deny both class hints, meaning the most derived 127 | * between left and right if one is a child of the other 128 | */ 129 | protected function findCommonDateTimeClass(?string $left, ?string $right): ?string 130 | { 131 | if (null === $right) { 132 | return $left; 133 | } 134 | if (null === $left) { 135 | return $right; 136 | } 137 | 138 | if (is_a($left, $right, true)) { 139 | return $left; 140 | } 141 | if (is_a($right, $left, true)) { 142 | return $right; 143 | } 144 | 145 | throw new \UnexpectedValueException("Collection classes '{$left}' and '{$right}' do not match."); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Metadata/PropertyTypeIterable.php: -------------------------------------------------------------------------------- 1 | , provided that T is, inherits from, or is a parent class of $this->collectionClass 11 | * This property type can be merged with PropertyTypeIterable, if : 12 | * - we're not merging a plain array PropertyTypeIterable into a hashmap one, 13 | * - and the collection classes of each are either not present on both sides, or are the same, or parent-child of one another 14 | */ 15 | final class PropertyTypeIterable extends PropertyTypeArray 16 | { 17 | /** 18 | * @var string 19 | */ 20 | private $traversableClass; 21 | 22 | /** 23 | * @param class-string<\Traversable>|null $traversableClass 24 | */ 25 | public function __construct(PropertyType $subType, bool $hashmap, bool $nullable, string $traversableClass = null) 26 | { 27 | parent::__construct($subType, $hashmap, $nullable, null != $traversableClass); 28 | 29 | $this->traversableClass = $traversableClass; 30 | } 31 | 32 | public function __toString(): string 33 | { 34 | if ($this->subType instanceof PropertyTypeUnknown) { 35 | return 'array'.($this->isTraversable() ? '|\\'.$this->traversableClass : ''); 36 | } 37 | 38 | $array = $this->isHashmap() ? '[string]' : '[]'; 39 | if ($this->isTraversable()) { 40 | $collectionType = $this->isHashmap() ? ', string' : ''; 41 | $array .= sprintf('|\\%s<%s%s>', $this->traversableClass, $this->subType, $collectionType); 42 | } 43 | 44 | return ((string) $this->subType).$array.AbstractPropertyType::__toString(); 45 | } 46 | 47 | /** 48 | * @deprecated Please prefer using {@link getTraversableClass} 49 | * 50 | * @return class-string|null 51 | */ 52 | public function getCollectionClass(): ?string 53 | { 54 | return $this->isCollection() ? null : $this->traversableClass; 55 | } 56 | 57 | /** 58 | * @deprecated Please prefer using {@link isTraversable} 59 | */ 60 | public function isCollection(): bool 61 | { 62 | return (null != $this->traversableClass) && is_a($this->traversableClass, Collection::class, true); 63 | } 64 | 65 | /** 66 | * @return class-string<\Traversable> 67 | */ 68 | public function getTraversableClass(): string 69 | { 70 | if (!$this->isTraversable()) { 71 | throw new \UnexpectedValueException("Iterable type '{$this}' is not traversable."); 72 | } 73 | 74 | return $this->traversableClass; 75 | } 76 | 77 | public function isTraversable(): bool 78 | { 79 | return null != $this->traversableClass; 80 | } 81 | 82 | public function merge(PropertyType $other): PropertyType 83 | { 84 | $nullable = $this->isNullable() && $other->isNullable(); 85 | $thisTraversableClass = $this->isTraversable() ? $this->getTraversableClass() : null; 86 | 87 | if ($other instanceof PropertyTypeUnknown) { 88 | return new self($this->subType, $this->isHashmap(), $nullable, $thisTraversableClass); 89 | } 90 | if ($this->isTraversable() && (($other instanceof PropertyTypeClass) && is_a($other->getClassName(), \Traversable::class, true))) { 91 | return new self($this->getSubType(), $this->isHashmap(), $nullable, $this->findCommonCollectionClass($thisTraversableClass, $other->getClassName())); 92 | } 93 | if (!$other instanceof parent) { 94 | throw new \UnexpectedValueException(sprintf('Can\'t merge type %s with %s, they must be the same or unknown', self::class, \get_class($other))); 95 | } 96 | 97 | /* 98 | * We allow converting array to hashmap (but not the other way round). 99 | * 100 | * PHPDoc has no clear definition for hashmaps with string indexes, but JMS Serializer annotations do. 101 | */ 102 | if ($this->isHashmap() && !$other->isHashmap()) { 103 | throw new \UnexpectedValueException(sprintf('Can\'t merge type %s with %s, can\'t change hashmap into plain array', self::class, \get_class($other))); 104 | } 105 | 106 | $otherTraversableClass = $other->isTraversable() ? $other->getTraversableClass() : null; 107 | $hashmap = $this->isHashmap() || $other->isHashmap(); 108 | $commonClass = $this->findCommonCollectionClass($thisTraversableClass, $otherTraversableClass); 109 | 110 | if ($other->getSubType() instanceof PropertyTypeUnknown) { 111 | return new self($this->getSubType(), $hashmap, $nullable, $commonClass); 112 | } 113 | if ($this->getSubType() instanceof PropertyTypeUnknown) { 114 | return new self($other->getSubType(), $hashmap, $nullable, $commonClass); 115 | } 116 | 117 | return new self($this->getSubType()->merge($other->getSubType()), $hashmap, $nullable, $commonClass); 118 | } 119 | 120 | /** 121 | * Find the most derived class that doesn't deny both class hints, meaning the most derived 122 | * between left and right if one is a child of the other 123 | */ 124 | private function findCommonCollectionClass(?string $left, ?string $right): ?string 125 | { 126 | if (null === $right) { 127 | return $left; 128 | } 129 | if (null === $left) { 130 | return $right; 131 | } 132 | 133 | if (is_a($left, $right, true)) { 134 | return $left; 135 | } 136 | if (is_a($right, $left, true)) { 137 | return $right; 138 | } 139 | 140 | throw new \UnexpectedValueException("Traversable classes '{$left}' and '{$right}' do not match."); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Metadata/PropertyTypePrimitive.php: -------------------------------------------------------------------------------- 1 | 'bool', 11 | 'integer' => 'int', 12 | 'double' => 'float', 13 | 'real' => 'float', 14 | ]; 15 | 16 | private const PRIMITIVE_TYPES = [ 17 | 'string', 18 | 'int', 19 | 'float', 20 | 'bool', 21 | ]; 22 | 23 | /** 24 | * @var string|null 25 | */ 26 | private $typeName; 27 | 28 | public function __construct(string $typeName, bool $nullable) 29 | { 30 | parent::__construct($nullable); 31 | if (\array_key_exists($typeName, self::TYPE_MAP)) { 32 | $typeName = self::TYPE_MAP[$typeName]; 33 | } 34 | if (!self::isTypePrimitive($typeName)) { 35 | throw new \UnexpectedValueException(sprintf('Given type "%s" is not primitive', $typeName)); 36 | } 37 | $this->typeName = $typeName; 38 | } 39 | 40 | public function __toString(): string 41 | { 42 | return $this->typeName.parent::__toString(); 43 | } 44 | 45 | public function getTypeName(): string 46 | { 47 | return $this->typeName; 48 | } 49 | 50 | public function merge(PropertyType $other): PropertyType 51 | { 52 | $nullable = $this->isNullable() && $other->isNullable(); 53 | 54 | if ($other instanceof PropertyTypeUnknown) { 55 | return new self($this->typeName, $nullable); 56 | } 57 | if (!$other instanceof self) { 58 | throw new \UnexpectedValueException(sprintf('Can\'t merge type %s with %s, they must be the same or unknown', self::class, \get_class($other))); 59 | } 60 | if ($this->getTypeName() !== $other->getTypeName()) { 61 | throw new \UnexpectedValueException(sprintf('Can\'t merge type %s with %s, they must be equal', self::class, \get_class($other))); 62 | } 63 | 64 | return new self($this->typeName, $nullable); 65 | } 66 | 67 | public static function isTypePrimitive(string $typeName): bool 68 | { 69 | if (\array_key_exists($typeName, self::TYPE_MAP)) { 70 | $typeName = self::TYPE_MAP[$typeName]; 71 | } 72 | 73 | return \in_array($typeName, self::PRIMITIVE_TYPES, true); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Metadata/PropertyTypeUnknown.php: -------------------------------------------------------------------------------- 1 | isNullable() && $other->isNullable()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Metadata/VersionRange.php: -------------------------------------------------------------------------------- 1 | since = $since; 25 | $this->until = $until; 26 | } 27 | 28 | public static function all(): self 29 | { 30 | return new self(null, null); 31 | } 32 | 33 | public function isDefined(): bool 34 | { 35 | return null !== $this->since || null !== $this->until; 36 | } 37 | 38 | /** 39 | * Check if this version range allows a lower version than the other version range. 40 | * 41 | * Returns false if both have the same lower bound. 42 | */ 43 | public function allowsLowerThan(self $other): bool 44 | { 45 | if (null === $other->since) { 46 | return false; 47 | } 48 | if (null === $this->since) { 49 | return true; 50 | } 51 | 52 | return version_compare($this->since, $other->since, '<'); 53 | } 54 | 55 | /** 56 | * Check if this version range allows a higher version than the other version range. 57 | * 58 | * Returns false if both have the same upper bound. 59 | */ 60 | public function allowsHigherThan(self $other) 61 | { 62 | if (null === $this->until) { 63 | return false; 64 | } 65 | if (null === $other->until) { 66 | return true; 67 | } 68 | 69 | return version_compare($this->until, $other->until, '>'); 70 | } 71 | 72 | /** 73 | * The lowest allowed version or `null` when there is no lower bound. 74 | */ 75 | public function getSince(): ?string 76 | { 77 | return $this->since; 78 | } 79 | 80 | /** 81 | * The highest allowed version or `null` when there is no upper bound. 82 | */ 83 | public function getUntil(): ?string 84 | { 85 | return $this->until; 86 | } 87 | 88 | public function withSince(string $since): self 89 | { 90 | $copy = clone $this; 91 | $copy->since = $since; 92 | 93 | return $copy; 94 | } 95 | 96 | public function withUntil(string $until): self 97 | { 98 | $copy = clone $this; 99 | $copy->until = $until; 100 | 101 | return $copy; 102 | } 103 | 104 | public function isIncluded(string $version): bool 105 | { 106 | if (null !== $this->since && version_compare($version, $this->since, '<')) { 107 | return false; 108 | } 109 | if (null !== $this->until && version_compare($version, $this->until, '>')) { 110 | return false; 111 | } 112 | 113 | return true; 114 | } 115 | 116 | public function jsonSerialize(): array 117 | { 118 | return array_filter([ 119 | 'since' => $this->since, 120 | 'until' => $this->until, 121 | ]); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/ModelParser/JMSParserLegacy.php: -------------------------------------------------------------------------------- 1 | setReadOnly($annotation->readOnly); 24 | 25 | return true; 26 | } 27 | 28 | return false; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ModelParser/LiipMetadataAnnotationParser.php: -------------------------------------------------------------------------------- 1 | annotationsReader = $annotationsReader; 32 | } 33 | 34 | public function parse(RawClassMetadata $classMetadata): void 35 | { 36 | try { 37 | $reflClass = new \ReflectionClass($classMetadata->getClassName()); 38 | } catch (\ReflectionException $e) { 39 | throw ParseException::classNotFound($classMetadata->getClassName(), $e); 40 | } 41 | 42 | $this->parseProperties($reflClass, $classMetadata); 43 | $this->parseMethods($reflClass, $classMetadata); 44 | } 45 | 46 | private function parseProperties(\ReflectionClass $reflClass, RawClassMetadata $classMetadata): void 47 | { 48 | if ($reflParentClass = $reflClass->getParentClass()) { 49 | $this->parseProperties($reflParentClass, $classMetadata); 50 | } 51 | 52 | foreach ($reflClass->getProperties() as $reflProperty) { 53 | if (!$classMetadata->hasPropertyVariation($reflProperty->getName())) { 54 | continue; 55 | } 56 | 57 | try { 58 | $annotations = $this->annotationsReader->getPropertyAnnotations($reflProperty); 59 | } catch (AnnotationException $e) { 60 | throw ParseException::propertyError((string) $classMetadata, $reflProperty->getName(), $e); 61 | } 62 | 63 | $property = $classMetadata->getPropertyVariation($reflProperty->getName()); 64 | $this->parsePropertyAnnotations($classMetadata, $property, $annotations); 65 | } 66 | } 67 | 68 | private function parseMethods(\ReflectionClass $reflClass, RawClassMetadata $classMetadata): void 69 | { 70 | if ($reflParentClass = $reflClass->getParentClass()) { 71 | $this->parseMethods($reflParentClass, $classMetadata); 72 | } 73 | 74 | foreach ($reflClass->getMethods() as $reflMethod) { 75 | if (false === $reflMethod->getDocComment()) { 76 | continue; 77 | } 78 | if (!$classMetadata->hasPropertyVariation($this->getMethodName($reflMethod))) { 79 | continue; 80 | } 81 | 82 | try { 83 | $annotations = $this->annotationsReader->getMethodAnnotations($reflMethod); 84 | } catch (AnnotationException $e) { 85 | throw ParseException::propertyError((string) $classMetadata, $reflMethod->getName(), $e); 86 | } 87 | 88 | $property = $classMetadata->getPropertyVariation($this->getMethodName($reflMethod)); 89 | $this->parsePropertyAnnotations($classMetadata, $property, $annotations); 90 | } 91 | } 92 | 93 | private function parsePropertyAnnotations(RawClassMetadata $classMetadata, PropertyVariationMetadata $property, array $annotations): void 94 | { 95 | foreach ($annotations as $annotation) { 96 | switch (true) { 97 | case $annotation instanceof Preferred: 98 | $property->setPreferred(true); 99 | break; 100 | 101 | default: 102 | if (0 === strncmp('Liip\MetadataParser\\', \get_class($annotation), mb_strlen('Liip\MetadataParser\\'))) { 103 | // if there are annotations we can safely ignore, we need to explicitly ignore them 104 | throw ParseException::unsupportedPropertyAnnotation((string) $classMetadata, (string) $property, \get_class($annotation)); 105 | } 106 | break; 107 | } 108 | } 109 | } 110 | 111 | private function getMethodName(\ReflectionMethod $reflMethod): string 112 | { 113 | $name = $reflMethod->getName(); 114 | if (0 === strpos($name, 'get')) { 115 | $name = lcfirst(substr($name, 3)); 116 | } 117 | 118 | return $name; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/ModelParser/ModelParserInterface.php: -------------------------------------------------------------------------------- 1 | root = $root; 24 | } 25 | 26 | public function __toString(): string 27 | { 28 | if (0 === \count($this->stack)) { 29 | return $this->root; 30 | } 31 | 32 | $stack = array_map(static function (PropertyVariationMetadata $propertyMetadata) { 33 | return $propertyMetadata->getName(); 34 | }, $this->stack); 35 | 36 | return sprintf('%s->%s', $this->root, implode('->', $stack)); 37 | } 38 | 39 | public function push(PropertyVariationMetadata $property): self 40 | { 41 | $context = clone $this; 42 | $context->stack[] = $property; 43 | 44 | return $context; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/ModelParser/PhpDocParser.php: -------------------------------------------------------------------------------- 1 | typeParser = new PhpTypeParser(); 25 | } 26 | 27 | public function parse(RawClassMetadata $classMetadata): void 28 | { 29 | try { 30 | $reflClass = new \ReflectionClass($classMetadata->getClassName()); 31 | } catch (\ReflectionException $e) { 32 | throw ParseException::classNotFound($classMetadata->getClassName(), $e); 33 | } 34 | 35 | $this->parseProperties($reflClass, $classMetadata); 36 | } 37 | 38 | /** 39 | * @return string[] the property names that have been added 40 | */ 41 | private function parseProperties(\ReflectionClass $reflClass, RawClassMetadata $classMetadata): array 42 | { 43 | $existingProperties = array_map(static function (PropertyCollection $prop): string { 44 | return (string) $prop; 45 | }, $classMetadata->getPropertyCollections()); 46 | 47 | $addedProperties = []; 48 | $parentProperties = []; 49 | if ($reflParentClass = $reflClass->getParentClass()) { 50 | $parentProperties = $this->parseProperties($reflParentClass, $classMetadata); 51 | } 52 | $parentPropertiesLookup = array_flip($parentProperties); 53 | 54 | foreach ($reflClass->getProperties() as $reflProperty) { 55 | if ($classMetadata->hasPropertyVariation($reflProperty->getName())) { 56 | $property = $classMetadata->getPropertyVariation($reflProperty->getName()); 57 | } else { 58 | $property = PropertyVariationMetadata::fromReflection($reflProperty); 59 | $classMetadata->addPropertyVariation($reflProperty->getName(), $property); 60 | } 61 | 62 | $docComment = $reflProperty->getDocComment(); 63 | if (false !== $docComment) { 64 | try { 65 | $type = $this->getPropertyTypeFromDocComment($docComment, $reflProperty); 66 | } catch (ParseException $e) { 67 | throw ParseException::propertyTypeError((string) $classMetadata, (string) $property, $e); 68 | } 69 | if (null === $type) { 70 | continue; 71 | } 72 | 73 | if ($property->getType() instanceof PropertyTypeUnknown || \array_key_exists((string) $property, $parentPropertiesLookup)) { 74 | $property->setType($type); 75 | } else { 76 | try { 77 | $property->setType($property->getType()->merge($type)); 78 | } catch (\UnexpectedValueException $e) { 79 | throw ParseException::propertyTypeConflict((string) $classMetadata, (string) $property, (string) $property->getType(), (string) $type, $e); 80 | } 81 | } 82 | $addedProperties[] = (string) $property; 83 | } 84 | } 85 | 86 | return array_values(array_diff(array_unique(array_merge($parentProperties, $addedProperties)), $existingProperties)); 87 | } 88 | 89 | private function getPropertyTypeFromDocComment(string $docComment, \ReflectionProperty $reflProperty): ?PropertyType 90 | { 91 | foreach (explode("\n", $docComment) as $line) { 92 | if (1 === preg_match('/@var ([^ ]+)/', $line, $matches)) { 93 | return $this->typeParser->parseAnnotationType($matches[1], $reflProperty->getDeclaringClass()); 94 | } 95 | } 96 | 97 | return null; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/ModelParser/RawMetadata/PropertyCollection.php: -------------------------------------------------------------------------------- 1 | setSerializedName($name); 24 | } 25 | 26 | public function __toString(): string 27 | { 28 | return $this->serializedName; 29 | } 30 | 31 | public static function serializedName(string $name): string 32 | { 33 | if (self::$identicalNamingStrategy) { 34 | return $name; 35 | } 36 | 37 | return strtolower(preg_replace('/[A-Z]/', '_\\0', $name)); 38 | } 39 | 40 | public static function useIdenticalNamingStrategy($value = true): void 41 | { 42 | self::$identicalNamingStrategy = $value; 43 | } 44 | 45 | /** 46 | * Try to determine the position of this collection in the supplied order. 47 | * 48 | * This goes over the variations and returns the index in $order of the 49 | * first variation that is found. If no variation is found in $order, null 50 | * is returned. 51 | * 52 | * Note: This strategy will not work when you want a different order for 53 | * different versions. When the properties/methods that result in the same 54 | * serialized name have to be ordered differently, we would need a 55 | * different approach. We would need to keep all properties separately - or 56 | * keep track of the PHP name and apply reordering after reducing. 57 | * 58 | * @param string[] $order Ordered list of property names (PHP code names, not serialized names) 59 | */ 60 | public function getPosition(array $order): ?int 61 | { 62 | foreach ($this->variations as $variation) { 63 | $pos = array_search($variation->getName(), $order, true); 64 | if (false !== $pos) { 65 | return $pos; 66 | } 67 | } 68 | 69 | return null; 70 | } 71 | 72 | public function setSerializedName(string $name): void 73 | { 74 | $this->serializedName = self::serializedName($name); 75 | } 76 | 77 | public function getSerializedName(): string 78 | { 79 | return $this->serializedName; 80 | } 81 | 82 | public function addVariation(PropertyVariationMetadata $property): void 83 | { 84 | $this->variations[] = $property; 85 | } 86 | 87 | /** 88 | * @return PropertyVariationMetadata[] 89 | */ 90 | public function getVariations(): array 91 | { 92 | return $this->variations; 93 | } 94 | 95 | public function hasVariation(string $name): bool 96 | { 97 | return null !== $this->findVariation($name); 98 | } 99 | 100 | /** 101 | * @param string $name The name of a PropertyVariation is its system name, e.g. for a virtual property the method name 102 | * 103 | * @throws \UnexpectedValueException if no variation with this name exists 104 | */ 105 | public function getVariation(string $name): PropertyVariationMetadata 106 | { 107 | $property = $this->findVariation($name); 108 | if (null === $property) { 109 | throw new \UnexpectedValueException(sprintf('Property variation %s not found on PropertyCollection %s', $name, $this->serializedName)); 110 | } 111 | 112 | return $property; 113 | } 114 | 115 | public function removeVariation(string $name): void 116 | { 117 | foreach ($this->variations as $i => $property) { 118 | if ($property->getName() === $name) { 119 | unset($this->variations[$i]); 120 | $this->variations = array_values($this->variations); 121 | 122 | break; 123 | } 124 | } 125 | } 126 | 127 | public function jsonSerialize(): array 128 | { 129 | return [ 130 | 'serialized_name' => $this->serializedName, 131 | 'variations' => $this->variations, 132 | ]; 133 | } 134 | 135 | private function findVariation(string $name): ?PropertyVariationMetadata 136 | { 137 | foreach ($this->variations as $property) { 138 | if ($property->getName() === $name) { 139 | return $property; 140 | } 141 | } 142 | 143 | return null; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/ModelParser/RawMetadata/PropertyVariationMetadata.php: -------------------------------------------------------------------------------- 1 | type = new PropertyTypeUnknown(true); 37 | $this->setPreferred($preferred); 38 | } 39 | 40 | public static function fromReflection(\ReflectionProperty $reflProperty): self 41 | { 42 | return new self($reflProperty->getName(), false, $reflProperty->isPublic()); 43 | } 44 | 45 | public function setType(PropertyType $type): void 46 | { 47 | $this->type = $type; 48 | } 49 | 50 | public function getType(): PropertyType 51 | { 52 | return $this->type; 53 | } 54 | 55 | public function setPreferred(bool $preferred): void 56 | { 57 | $this->preferred = $preferred; 58 | } 59 | 60 | public function isPreferred(): bool 61 | { 62 | return $this->preferred; 63 | } 64 | 65 | public function setReadOnly(bool $readOnly): void 66 | { 67 | parent::setReadOnly($readOnly); 68 | } 69 | 70 | public function setPublic(bool $public): void 71 | { 72 | parent::setPublic($public); 73 | } 74 | 75 | public function setGroups(array $groups): void 76 | { 77 | parent::setGroups($groups); 78 | } 79 | 80 | public function setAccessor(PropertyAccessor $accessor): void 81 | { 82 | parent::setAccessor($accessor); 83 | } 84 | 85 | public function setVersionRange(VersionRange $version): void 86 | { 87 | parent::setVersionRange($version); 88 | } 89 | 90 | public function getMaxDepth(): ?int 91 | { 92 | return parent::getMaxDepth(); 93 | } 94 | 95 | public function setMaxDepth(?int $maxDepth): void 96 | { 97 | parent::setMaxDepth($maxDepth); 98 | } 99 | 100 | /** 101 | * The value can be anything that the consumer understands. 102 | * 103 | * However, if it is an object, it should implement JsonSerializable to not 104 | * break debugging. 105 | */ 106 | public function setCustomInformation(string $key, $value): void 107 | { 108 | parent::setCustomInformation($key, $value); 109 | } 110 | 111 | public function jsonSerialize(): array 112 | { 113 | $data = parent::jsonSerialize(); 114 | $data['type'] = (string) $this->type; 115 | 116 | return $data; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/ModelParser/ReflectionParser.php: -------------------------------------------------------------------------------- 1 | typeParser = new PhpTypeParser(); 30 | $this->reflectionSupportsPropertyType = version_compare(\PHP_VERSION, '7.4', '>='); 31 | } 32 | 33 | public function parse(RawClassMetadata $classMetadata): void 34 | { 35 | try { 36 | $reflClass = new \ReflectionClass($classMetadata->getClassName()); 37 | } catch (\ReflectionException $e) { 38 | throw ParseException::classNotFound($classMetadata->getClassName(), $e); 39 | } 40 | 41 | $this->parseProperties($reflClass, $classMetadata); 42 | $this->parseConstructor($reflClass, $classMetadata); 43 | } 44 | 45 | private function parseProperties(\ReflectionClass $reflClass, RawClassMetadata $classMetadata): void 46 | { 47 | if ($reflParentClass = $reflClass->getParentClass()) { 48 | $this->parseProperties($reflParentClass, $classMetadata); 49 | } 50 | 51 | foreach ($reflClass->getProperties() as $reflProperty) { 52 | $type = null; 53 | $reflectionType = $this->reflectionSupportsPropertyType ? $reflProperty->getType() : null; 54 | if ($reflectionType instanceof \ReflectionNamedType) { 55 | // If the field has a union type (since PHP 8.0) or intersection type (since PHP 8.1), 56 | // the type would be a different kind of ReflectionType than ReflectionNamedType. 57 | // We don't have support in the metadata model to handle multiple types. 58 | $type = $this->typeParser->parseReflectionType($reflectionType); 59 | } 60 | if ($classMetadata->hasPropertyVariation($reflProperty->getName())) { 61 | $property = $classMetadata->getPropertyVariation($reflProperty->getName()); 62 | $property->setPublic($reflProperty->isPublic()); 63 | if ($type) { 64 | $property->setType($type); 65 | } 66 | } else { 67 | $property = PropertyVariationMetadata::fromReflection($reflProperty); 68 | if ($type) { 69 | $property->setType($type); 70 | } 71 | $classMetadata->addPropertyVariation($reflProperty->getName(), $property); 72 | } 73 | } 74 | } 75 | 76 | private function parseConstructor(\ReflectionClass $reflClass, RawClassMetadata $classMetadata): void 77 | { 78 | $constructor = $reflClass->getConstructor(); 79 | if (null === $constructor) { 80 | return; 81 | } 82 | 83 | foreach ($constructor->getParameters() as $reflParameter) { 84 | $classMetadata->addConstructorParameter(ParameterMetadata::fromReflection($reflParameter)); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/ModelParser/VisibilityAwarePropertyAccessGuesser.php: -------------------------------------------------------------------------------- 1 | getClassName()); 28 | } catch (\ReflectionException $e) { 29 | throw ParseException::classNotFound($classMetadata->getClassName(), $e); 30 | } 31 | 32 | $this->parseProperties($reflClass, $classMetadata); 33 | } 34 | 35 | public function parseProperties(\ReflectionClass $reflClass, RawClassMetadata $classMetadata): void 36 | { 37 | if ($reflParentClass = $reflClass->getParentClass()) { 38 | $this->parseProperties($reflParentClass, $classMetadata); 39 | } 40 | 41 | foreach ($reflClass->getProperties() as $property) { 42 | if ($property->isPublic() || !$classMetadata->hasPropertyVariation($property->getName())) { 43 | continue; 44 | } 45 | 46 | $variation = $classMetadata->getPropertyVariation($property->getName()); 47 | $currentAccessor = $variation->getAccessor(); 48 | 49 | if (!($currentAccessor->hasGetterMethod() && $currentAccessor->hasSetterMethod())) { 50 | $variation->setAccessor(new PropertyAccessor( 51 | $currentAccessor->getGetterMethod() ?: $this->guessGetter($reflClass, $variation), 52 | $currentAccessor->getSetterMethod() ?: $this->guessSetter($reflClass, $variation), 53 | )); 54 | } 55 | } 56 | } 57 | 58 | /** 59 | * Find a getter method for property, using prefixes `get`, `is`, or simply no prefix 60 | */ 61 | private function guessGetter(\ReflectionClass $reflClass, PropertyVariationMetadata $variation): ?string 62 | { 63 | foreach (['get', 'is', ''] as $prefix) { 64 | $method = "{$prefix}{$variation->getName()}"; 65 | 66 | if (!$reflClass->hasMethod($method)) { 67 | continue; 68 | } 69 | 70 | $reflMethod = $reflClass->getMethod($method); 71 | 72 | if ($reflMethod->isPublic() && (0 === $reflMethod->getNumberOfRequiredParameters())) { 73 | return $method; 74 | } 75 | } 76 | 77 | return null; 78 | } 79 | 80 | /** 81 | * Find a setter method for property, using prefix `set`, or simply no prefix 82 | */ 83 | private function guessSetter(\ReflectionClass $reflClass, PropertyVariationMetadata $variation): ?string 84 | { 85 | foreach (['set', ''] as $prefix) { 86 | $method = "{$prefix}{$variation->getName()}"; 87 | 88 | if (!$reflClass->hasMethod($method)) { 89 | continue; 90 | } 91 | 92 | $reflMethod = $reflClass->getMethod($method); 93 | 94 | if ($reflMethod->isPublic() && (1 === $reflMethod->getNumberOfRequiredParameters())) { 95 | return $method; 96 | } 97 | } 98 | 99 | return null; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | parsers = $parsers; 27 | } 28 | 29 | /** 30 | * @return RawClassMetadata[] 31 | * 32 | * @throws ParseException 33 | */ 34 | public function parse(string $className): array 35 | { 36 | $registry = new RawClassMetadataRegistry(); 37 | 38 | $this->parseModel($className, new ParserContext($className), $registry); 39 | 40 | return $registry->getAll(); 41 | } 42 | 43 | private function parseModel(string $className, ParserContext $context, RawClassMetadataRegistry $registry): void 44 | { 45 | if ($registry->contains($className)) { 46 | return; 47 | } 48 | 49 | $rawClassMetadata = new RawClassMetadata($className); 50 | foreach ($this->parsers as $parser) { 51 | $parser->parse($rawClassMetadata); 52 | } 53 | $registry->add($rawClassMetadata); 54 | 55 | foreach ($rawClassMetadata->getPropertyVariations() as $property) { 56 | $type = $property->getType(); 57 | if ($type instanceof PropertyTypeIterable) { 58 | $type = $type->getLeafType(); 59 | } 60 | if ($type instanceof PropertyTypeClass) { 61 | $this->parseModel($type->getClassName(), $context->push($property), $registry); 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/PropertyReducer.php: -------------------------------------------------------------------------------- 1 | getPropertyCollections() as $propertyCollection) { 22 | $properties = []; 23 | foreach ($propertyCollection->getVariations() as $property) { 24 | $properties[] = $property; 25 | } 26 | foreach ($reducers as $reducer) { 27 | $properties = $reducer->reduce($propertyCollection->getSerializedName(), $properties); 28 | } 29 | 30 | if (\count($properties) > 0) { 31 | $classProperties[] = PropertyMetadata::fromRawProperty($propertyCollection->getSerializedName(), $properties[0]); 32 | } 33 | } 34 | 35 | return ClassMetadata::fromRawClassMetadata($rawClassMetadata, $classProperties); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/RawClassMetadataRegistry.php: -------------------------------------------------------------------------------- 1 | contains($classMetadata->getClassName())) { 19 | throw new \BadMethodCallException(sprintf('The model for "%s" is already in the registry', $classMetadata->getClassName())); 20 | } 21 | 22 | $this->classMetadata[$classMetadata->getClassName()] = $classMetadata; 23 | } 24 | 25 | public function contains(string $className): bool 26 | { 27 | return \array_key_exists($className, $this->classMetadata); 28 | } 29 | 30 | /** 31 | * @return RawClassMetadata[] 32 | */ 33 | public function getAll(): array 34 | { 35 | return array_values($this->classMetadata); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/RecursionChecker.php: -------------------------------------------------------------------------------- 1 | variants (which themselves are products. You can configure to 23 | * abort at product->variants->variants to avoid a recursion. 24 | */ 25 | final class RecursionChecker 26 | { 27 | /** 28 | * @var LoggerInterface|null 29 | */ 30 | private $logger; 31 | 32 | /** 33 | * @var string[][] 34 | */ 35 | private $expectedRecursions; 36 | 37 | /** 38 | * The expected recursions can be absolute, starting with the root class. 39 | * Or you can specify only a sub path. Both will be detected. 40 | * 41 | * A recursion configuration may contain Context::MATCH_EVERYTHING ('*') to 42 | * match every field at that level. 43 | * 44 | * Arrays and hashmaps are specified the same as single fields. 45 | * 46 | * @param string[][] $expectedRecursions List of expected recursions 47 | */ 48 | public function __construct(LoggerInterface $logger = null, array $expectedRecursions = []) 49 | { 50 | $this->logger = $logger; 51 | $this->expectedRecursions = $expectedRecursions; 52 | } 53 | 54 | /** 55 | * @throws RecursionException 56 | */ 57 | public function check(ClassMetadata $classMetadata): ClassMetadata 58 | { 59 | return $this->checkClassMetadata($classMetadata, new RecursionContext($classMetadata->getClassName())); 60 | } 61 | 62 | private function checkClassMetadata(ClassMetadata $classMetadata, RecursionContext $context): ClassMetadata 63 | { 64 | $propertiesToRemove = []; 65 | 66 | foreach ($classMetadata->getProperties() as $property) { 67 | $type = $property->getType(); 68 | if ($type instanceof PropertyTypeIterable) { 69 | $type = $type->getLeafType(); 70 | } 71 | 72 | if ($type instanceof PropertyTypeClass) { 73 | $propertyClassMetadata = $type->getClassMetadata(); 74 | $propertyContext = $context->push($property); 75 | 76 | foreach ($this->expectedRecursions as $expectedRecursion) { 77 | /* 78 | * Future feature idea: The expected paths would work just the same if they where not about 79 | * recursions but general paths. We could move the check for circuit breaking outside of 80 | * the check whether we hit a recursion. 81 | */ 82 | if ($propertyContext->matches($expectedRecursion)) { 83 | // Remove property of expected recursion 84 | if ($this->logger) { 85 | $this->logger->notice( 86 | 'Expected recursion found for class "{class_name}" in context {context}', 87 | [ 88 | 'class_name' => $propertyClassMetadata->getClassName(), 89 | 'context' => (string) $propertyContext, 90 | ] 91 | ); 92 | } 93 | 94 | $propertiesToRemove[] = $property->getName(); 95 | continue 2; 96 | } 97 | } 98 | 99 | $maxDepth = $property->getMaxDepth(); 100 | $stackCount = $propertyContext->countClassNames($classMetadata->getClassName()); 101 | if (null === $maxDepth && $stackCount > 2) { 102 | throw RecursionException::forClass($propertyClassMetadata->getClassName(), $propertyContext); 103 | } 104 | 105 | if (null !== $maxDepth && $stackCount > $maxDepth) { 106 | return $classMetadata; 107 | } 108 | 109 | $type->setClassMetadata($this->checkClassMetadata($propertyClassMetadata, $propertyContext)); 110 | } 111 | } 112 | 113 | if (0 === \count($propertiesToRemove)) { 114 | return $classMetadata; 115 | } 116 | 117 | return $classMetadata->withoutProperties($propertiesToRemove); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/RecursionContext.php: -------------------------------------------------------------------------------- 1 | root = $root; 28 | } 29 | 30 | public function __toString(): string 31 | { 32 | if (0 === \count($this->stack)) { 33 | return $this->root; 34 | } 35 | 36 | $stack = array_map(static function (PropertyMetadata $propertyMetadata) { 37 | return $propertyMetadata->getSerializedName(); 38 | }, $this->stack); 39 | 40 | return sprintf('%s->%s', $this->root, implode('->', $stack)); 41 | } 42 | 43 | public function push(PropertyMetadata $property): self 44 | { 45 | $context = clone $this; 46 | $context->stack[] = $property; 47 | 48 | return $context; 49 | } 50 | 51 | /** 52 | * Check if we are at the specified stack. 53 | * 54 | * @see RecursionChecker 55 | * 56 | * @param string[] $stackToCheck List of optional root class and properties to go through 57 | */ 58 | public function matches(array $stackToCheck): bool 59 | { 60 | if (0 === \count($stackToCheck) || \count($this->stack) + 1 < \count($stackToCheck)) { 61 | return false; 62 | } 63 | 64 | $current = [$this->root]; 65 | foreach ($this->stack as $property) { 66 | $current[] = $property->getSerializedName(); 67 | } 68 | 69 | foreach ($current as $i => $name) { 70 | if ($stackToCheck[0] === $name) { 71 | $valid = true; 72 | foreach ($stackToCheck as $j => $nameToCheck) { 73 | if (self::MATCH_EVERYTHING !== $nameToCheck && ($current[$i + (int) $j] ?? null) !== $nameToCheck) { 74 | $valid = false; 75 | break; 76 | } 77 | } 78 | 79 | if ($valid) { 80 | return true; 81 | } 82 | } 83 | } 84 | 85 | return false; 86 | } 87 | 88 | public function countClassNames(string $className): int 89 | { 90 | $count = 0; 91 | if ($this->root === $className) { 92 | ++$count; 93 | } 94 | foreach ($this->stack as $property) { 95 | $type = $property->getType(); 96 | if ($type instanceof PropertyTypeIterable) { 97 | $type = $type->getLeafType(); 98 | } 99 | if ($type instanceof PropertyTypeClass && $type->getClassName() === $className) { 100 | ++$count; 101 | } 102 | } 103 | 104 | return $count; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Reducer/GroupReducer.php: -------------------------------------------------------------------------------- 1 | groups = $groups; 25 | } 26 | 27 | public function reduce(string $serializedName, array $properties): array 28 | { 29 | $includedProperties = []; 30 | foreach ($properties as $property) { 31 | if ($this->includeProperty($property)) { 32 | $includedProperties[] = $property; 33 | } 34 | } 35 | 36 | return $includedProperties; 37 | } 38 | 39 | private function includeProperty(PropertyVariationMetadata $property): bool 40 | { 41 | return 0 === \count($this->groups) || 0 < \count(array_intersect($property->getGroups(), $this->groups)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Reducer/PreferredReducer.php: -------------------------------------------------------------------------------- 1 | isPreferred(); 21 | })); 22 | 23 | if (\count($preferred)) { 24 | return $preferred; 25 | } 26 | 27 | return $properties; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Reducer/PropertyReducerInterface.php: -------------------------------------------------------------------------------- 1 | getName()) { 23 | return -1; 24 | } 25 | if ($serializedName === $propertyB->getName()) { 26 | return 1; 27 | } 28 | 29 | return 0; 30 | }); 31 | 32 | return $properties; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Reducer/VersionReducer.php: -------------------------------------------------------------------------------- 1 | version = $version; 20 | } 21 | 22 | public function reduce(string $serializedName, array $properties): array 23 | { 24 | $includedProperties = []; 25 | foreach ($properties as $property) { 26 | if ($property->getVersionRange()->isIncluded($this->version)) { 27 | $includedProperties[] = $property; 28 | } 29 | } 30 | 31 | return $includedProperties; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/TypeParser/JMSTypeParser.php: -------------------------------------------------------------------------------- 1 | jmsTypeParser = new Parser(); 33 | } 34 | 35 | public function parse(string $rawType): PropertyType 36 | { 37 | if ('' === $rawType) { 38 | return new PropertyTypeUnknown(true); 39 | } 40 | 41 | return $this->parseType($this->jmsTypeParser->parse($rawType)); 42 | } 43 | 44 | private function parseType(array $typeInfo, bool $isSubType = false): PropertyType 45 | { 46 | $typeInfo = array_merge( 47 | [ 48 | 'name' => null, 49 | 'params' => [], 50 | ], 51 | $typeInfo 52 | ); 53 | 54 | // JMS types are nullable except if it's a sub type (part of array) 55 | $nullable = !$isSubType; 56 | 57 | if (0 === \count($typeInfo['params'])) { 58 | if (self::TYPE_ARRAY === $typeInfo['name']) { 59 | return new PropertyTypeIterable(new PropertyTypeUnknown(false), false, $nullable); 60 | } 61 | 62 | if (PropertyTypePrimitive::isTypePrimitive($typeInfo['name'])) { 63 | return new PropertyTypePrimitive($typeInfo['name'], $nullable); 64 | } 65 | if (PropertyTypeDateTime::isTypeDateTime($typeInfo['name'])) { 66 | return PropertyTypeDateTime::fromDateTimeClass($typeInfo['name'], $nullable); 67 | } 68 | 69 | return new PropertyTypeClass($typeInfo['name'], $nullable); 70 | } 71 | 72 | $collectionClass = $this->getCollectionClass($typeInfo['name']); 73 | if (self::TYPE_ARRAY === $typeInfo['name'] || $collectionClass) { 74 | if (1 === \count($typeInfo['params'])) { 75 | return new PropertyTypeIterable($this->parseType($typeInfo['params'][0], true), false, $nullable, $collectionClass); 76 | } 77 | if (2 === \count($typeInfo['params'])) { 78 | return new PropertyTypeIterable($this->parseType($typeInfo['params'][1], true), true, $nullable, $collectionClass); 79 | } 80 | 81 | throw new InvalidTypeException(sprintf('JMS property type array can\'t have more than 2 parameters (%s)', var_export($typeInfo, true))); 82 | } 83 | 84 | if (PropertyTypeDateTime::isTypeDateTime($typeInfo['name']) || (self::TYPE_DATETIME_INTERFACE === $typeInfo['name'])) { 85 | // the case of datetime without params is already handled above, we know we have params 86 | $serializeFormat = $typeInfo['params'][0] ?: null; 87 | // {@link \JMS\Serializer\Handler\DateHandler} of jms/serializer defaults to using the serialization format as a deserialization format if none was supplied... 88 | $deserializeFormats = ($typeInfo['params'][2] ?? null) ?: $serializeFormat; 89 | // ... and always converts single strings to arrays 90 | $deserializeFormats = \is_string($deserializeFormats) ? [$deserializeFormats] : $deserializeFormats; 91 | // Jms defaults to DateTime when given DateTimeInterface despite the documentation saying DateTimeImmutable, {@see \JMS\Serializer\Handler\DateHandler} in jms/serializer 92 | $className = (self::TYPE_DATETIME_INTERFACE === $typeInfo['name']) ? \DateTime::class : $typeInfo['name']; 93 | 94 | return PropertyTypeDateTime::fromDateTimeClass( 95 | $className, 96 | $nullable, 97 | new DateTimeOptions( 98 | $serializeFormat, 99 | ($typeInfo['params'][1] ?? null) ?: null, 100 | $deserializeFormats, 101 | ) 102 | ); 103 | } 104 | 105 | throw new InvalidTypeException(sprintf('Unknown JMS property found (%s)', var_export($typeInfo, true))); 106 | } 107 | 108 | private function getCollectionClass(string $name): ?string 109 | { 110 | switch ($name) { 111 | case self::TYPE_ARRAY_COLLECTION: 112 | return ArrayCollection::class; 113 | default: 114 | return is_a($name, Collection::class, true) ? $name : null; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/TypeParser/PhpTypeParser.php: -------------------------------------------------------------------------------- 1 | useStatementsParser = new PhpParser(); 39 | } 40 | 41 | /** 42 | * @throws InvalidTypeException if an invalid type or multiple types were defined 43 | */ 44 | public function parseAnnotationType(string $rawType, \ReflectionClass $declaringClass): PropertyType 45 | { 46 | if ('' === $rawType) { 47 | return new PropertyTypeUnknown(true); 48 | } 49 | 50 | $types = []; 51 | $nullable = false; 52 | foreach (explode(self::TYPE_SEPARATOR, $rawType) as $part) { 53 | if (self::TYPE_NULL === $part || self::TYPE_MIXED === $part) { 54 | $nullable = true; 55 | } elseif (!\in_array($part, self::TYPES_GENERIC, true)) { 56 | $types[] = $part; 57 | } 58 | } 59 | 60 | $collectionClass = null; 61 | $filteredTypes = []; 62 | foreach ($types as $type) { 63 | $resolvedClass = $this->resolveClass($type, $declaringClass); 64 | if (is_a($resolvedClass, Collection::class, true)) { 65 | $collectionClass = $resolvedClass; 66 | } else { 67 | $filteredTypes[] = $type; 68 | } 69 | } 70 | 71 | if (0 === \count($filteredTypes)) { 72 | return new PropertyTypeUnknown($nullable); 73 | } 74 | if (\count($filteredTypes) > 1) { 75 | throw new InvalidTypeException(sprintf('Multiple types are not supported (%s)', $rawType)); 76 | } 77 | 78 | return $this->createType($filteredTypes[0], $nullable, $declaringClass, $collectionClass); 79 | } 80 | 81 | /** 82 | * @throws InvalidTypeException if an invalid type was defined 83 | */ 84 | public function parseReflectionType(\ReflectionType $reflType): PropertyType 85 | { 86 | if ($reflType instanceof \ReflectionNamedType) { 87 | return $this->createType($reflType->getName(), $reflType->allowsNull()); 88 | } 89 | 90 | throw new InvalidTypeException(sprintf('No type information found, got %s but expected %s', \ReflectionType::class, \ReflectionNamedType::class)); 91 | } 92 | 93 | private function createType(string $rawType, bool $nullable, \ReflectionClass $reflClass = null, string $collectionClass = null): PropertyType 94 | { 95 | if (self::TYPE_ARRAY === $rawType) { 96 | return new PropertyTypeIterable(new PropertyTypeUnknown(false), false, $nullable); 97 | } 98 | 99 | if (self::TYPE_ARRAY_SUFFIX === substr($rawType, -\strlen(self::TYPE_ARRAY_SUFFIX))) { 100 | $rawSubType = substr($rawType, 0, \strlen($rawType) - \strlen(self::TYPE_ARRAY_SUFFIX)); 101 | 102 | return new PropertyTypeIterable($this->createType($rawSubType, false, $reflClass), false, $nullable, $collectionClass); 103 | } 104 | if (self::TYPE_HASHMAP_SUFFIX === substr($rawType, -\strlen(self::TYPE_HASHMAP_SUFFIX))) { 105 | $rawSubType = substr($rawType, 0, \strlen($rawType) - \strlen(self::TYPE_HASHMAP_SUFFIX)); 106 | 107 | return new PropertyTypeIterable($this->createType($rawSubType, false, $reflClass), true, $nullable, $collectionClass); 108 | } 109 | 110 | if (self::TYPE_RESOURCE === $rawType) { 111 | throw new InvalidTypeException('Type "resource" is not supported'); 112 | } 113 | 114 | if (PropertyTypePrimitive::isTypePrimitive($rawType)) { 115 | return new PropertyTypePrimitive($rawType, $nullable); 116 | } 117 | 118 | $resolvedClass = $this->resolveClass($rawType, $reflClass); 119 | 120 | if (PropertyTypeDateTime::isTypeDateTime($resolvedClass)) { 121 | return PropertyTypeDateTime::fromDateTimeClass($resolvedClass, $nullable); 122 | } 123 | 124 | return new PropertyTypeClass($resolvedClass, $nullable); 125 | } 126 | 127 | private function resolveClass(string $className, \ReflectionClass $reflClass = null): string 128 | { 129 | // leading backslash means absolute class name 130 | if (0 === strpos($className, '\\')) { 131 | return substr($className, 1); 132 | } 133 | 134 | if (null !== $reflClass) { 135 | // resolve use statements of the class with the type information 136 | $lowerClassName = strtolower($className); 137 | 138 | $reflCurrentClass = $reflClass; 139 | do { 140 | $imports = $this->useStatementsParser->parseUseStatements($reflCurrentClass); 141 | if (isset($imports[$lowerClassName])) { 142 | return $imports[$lowerClassName]; 143 | } 144 | } while (false !== ($reflCurrentClass = $reflCurrentClass->getParentClass())); 145 | 146 | foreach ($reflClass->getTraits() as $reflTrait) { 147 | $imports = $this->useStatementsParser->parseUseStatements($reflTrait); 148 | if (isset($imports[$lowerClassName])) { 149 | return $imports[$lowerClassName]; 150 | } 151 | } 152 | 153 | // the referenced class is expected to be in the same namespace 154 | $namespace = $reflClass->getNamespaceName(); 155 | if ('' !== $namespace) { 156 | return $namespace.'\\'.$className; 157 | } 158 | } 159 | 160 | // edge case of models defined in the global namespace 161 | return $className; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /tests/BuilderTest.php: -------------------------------------------------------------------------------- 1 | builder = new Builder( 39 | $parser, 40 | new RecursionChecker($this->createMock(LoggerInterface::class)) 41 | ); 42 | } 43 | 44 | public function testBuild(): void 45 | { 46 | $c = new class() { 47 | /** 48 | * @var Nested 49 | */ 50 | private $property; 51 | }; 52 | 53 | $classMetadata = $this->builder->build(\get_class($c)); 54 | 55 | $props = $classMetadata->getProperties(); 56 | $this->assertCount(1, $props, 'Number of properties should match'); 57 | 58 | $this->assertProperty('property', 'property', false, false, $props[0]); 59 | $this->assertPropertyType($props[0]->getType(), PropertyTypeClass::class, Nested::class, false); 60 | 61 | $type = $props[0]->getType(); 62 | $this->assertInstanceOf(PropertyTypeClass::class, $type); 63 | $nestedMetadata = $type->getClassMetadata(); 64 | $props = $nestedMetadata->getProperties(); 65 | $this->assertCount(1, $props, 'Number of properties should match'); 66 | $this->assertProperty('nestedProperty', 'nested_property', false, false, $props[0]); 67 | } 68 | 69 | private function assertProperty(string $name, string $serializedName, bool $public, bool $readOnly, PropertyMetadata $property): void 70 | { 71 | $this->assertSame($name, $property->getName(), 'Name of property should match'); 72 | $this->assertSame($serializedName, $property->getSerializedName(), "Serialized name of property {$name} should match"); 73 | $this->assertSame($public, $property->isPublic(), "Public flag of property {$name} should match"); 74 | $this->assertSame($readOnly, $property->isReadOnly(), "Read only flag of property {$name} should match"); 75 | } 76 | 77 | private function assertPropertyType(PropertyType $type, string $propertyTypeClass, string $typeString, bool $nullable): void 78 | { 79 | $this->assertInstanceOf($propertyTypeClass, $type); 80 | $this->assertSame($nullable, $type->isNullable()); 81 | $this->assertSame($typeString, (string) $type); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/Metadata/ClassMetadataTest.php: -------------------------------------------------------------------------------- 1 | addConstructorParameter(new ParameterMetadata('arg', true)); 22 | $rawClassMetadata->addConstructorParameter(new ParameterMetadata('withDefault', false, 'default')); 23 | $rawClassMetadata->addConstructorParameter(new ParameterMetadata('optional', false)); 24 | $rawClassMetadata->addPostDeserializeMethod('postDeserialize'); 25 | $properties = [new PropertyMetadata('test', 'testProperty')]; 26 | 27 | $classMetadata = ClassMetadata::fromRawClassMetadata($rawClassMetadata, $properties); 28 | $this->assertSame('Foo', $classMetadata->getClassName()); 29 | 30 | $constructorParameters = $classMetadata->getConstructorParameters(); 31 | $this->assertCount(3, $constructorParameters); 32 | $this->assertSame('arg', $constructorParameters[0]->getName()); 33 | $this->assertSame('withDefault', $constructorParameters[1]->getName()); 34 | $this->assertSame('optional', $constructorParameters[2]->getName()); 35 | 36 | $postDeserializeMethods = $classMetadata->getPostDeserializeMethods(); 37 | $this->assertCount(1, $postDeserializeMethods); 38 | $this->assertSame('postDeserialize', $postDeserializeMethods[0]); 39 | 40 | $props = $classMetadata->getProperties(); 41 | $this->assertCount(1, $props); 42 | $this->assertSame('testProperty', $props[0]->getName()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Metadata/PropertyTypeTest.php: -------------------------------------------------------------------------------- 1 | merge($typeB); 106 | 107 | $this->assertSame($expectedType, (string) $result); 108 | $this->assertSame($expectedNullable, $result->isNullable(), 'Nullable flag should match'); 109 | } 110 | 111 | /** 112 | * Special case: array to hashmap is allowed. hashmap to array is not. 113 | */ 114 | public function testUpgradeToHashmap(): void 115 | { 116 | $array = new PropertyTypeIterable(new PropertyTypePrimitive('bool', false), false, true); 117 | $hashmap = new PropertyTypeIterable(new PropertyTypePrimitive('bool', false), true, true); 118 | 119 | /** @var PropertyTypeIterable $merged */ 120 | $merged = $array->merge($hashmap); 121 | $this->assertInstanceOf(PropertyTypeIterable::class, $merged); 122 | $this->assertTrue($merged->isNullable()); 123 | $this->assertTrue($merged->isHashmap()); 124 | /** @var PropertyTypePrimitive $inner */ 125 | $inner = $merged->getSubType(); 126 | $this->assertInstanceOf(PropertyTypePrimitive::class, $inner); 127 | $this->assertSame('bool', $inner->getTypeName()); 128 | 129 | $this->expectException(\UnexpectedValueException::class); 130 | $hashmap->merge($array); 131 | } 132 | 133 | public function testMergeInvalidTypes(): void 134 | { 135 | $types = $this->getDifferentTypes(); 136 | 137 | foreach ($types as $typeA) { 138 | foreach ($types as $typeB) { 139 | if ($typeA === $typeB || $typeB instanceof PropertyTypeUnknown) { 140 | continue; 141 | } 142 | 143 | try { 144 | $typeA->merge($typeB); 145 | $this->fail(sprintf('Merge of %s into %s should not be possible', (string) $typeB, (string) $typeA)); 146 | } catch (\UnexpectedValueException $e) { 147 | $this->assertStringContainsString('merge', $e->getMessage()); 148 | } 149 | } 150 | } 151 | } 152 | 153 | /** 154 | * @return PropertyType[] 155 | */ 156 | private function getDifferentTypes(): array 157 | { 158 | return [ 159 | new PropertyTypeUnknown(true), 160 | new PropertyTypePrimitive('string', true), 161 | new PropertyTypePrimitive('int', true), 162 | new PropertyTypeDateTime(false, true), 163 | new PropertyTypeDateTime(true, true), 164 | new PropertyTypeClass(\stdClass::class, true), 165 | new PropertyTypeIterable(new PropertyTypePrimitive('bool', false), false, true), 166 | new PropertyTypeIterable(new PropertyTypePrimitive('int', false), false, true), 167 | new PropertyTypeIterable(new PropertyTypePrimitive('string', false), true, true), 168 | ]; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /tests/Metadata/VersionRangeTest.php: -------------------------------------------------------------------------------- 1 | assertNull($versionRange->getSince()); 19 | $this->assertNull($versionRange->getUntil()); 20 | } 21 | 22 | public function testDefined(): void 23 | { 24 | $versionRange = new VersionRange(null, null); 25 | $this->assertFalse($versionRange->isDefined()); 26 | 27 | $versionRange = new VersionRange('1', null); 28 | $this->assertTrue($versionRange->isDefined()); 29 | 30 | $versionRange = new VersionRange(null, '1'); 31 | $this->assertTrue($versionRange->isDefined()); 32 | } 33 | 34 | public function testWithSince(): void 35 | { 36 | $version = new VersionRange('1', '2'); 37 | $new = $version->withSince('2'); 38 | $this->assertNotSame($version, $new); 39 | $this->assertSame('2', $new->getSince()); 40 | $this->assertSame('1', $version->getSince()); 41 | } 42 | 43 | public function testWithUntil(): void 44 | { 45 | $version = new VersionRange('1', '2'); 46 | $new = $version->withUntil('3'); 47 | $this->assertNotSame($version, $new); 48 | $this->assertSame('3', $new->getUntil()); 49 | $this->assertSame('2', $version->getUntil()); 50 | } 51 | 52 | public function provideIsIncludedCases(): iterable 53 | { 54 | return [ 55 | 'null is lowest and highest' => [ 56 | new VersionRange(null, null), 57 | '0', 58 | true, 59 | ], 60 | 'lower bound' => [ 61 | new VersionRange('0', '1'), 62 | '0', 63 | true, 64 | ], 65 | 'somewhere' => [ 66 | new VersionRange('1', '2'), 67 | '1', 68 | true, 69 | ], 70 | 'upper bound' => [ 71 | new VersionRange('1', '2'), 72 | '2', 73 | true, 74 | ], 75 | 'below' => [ 76 | new VersionRange('2', '3'), 77 | '1', 78 | false, 79 | ], 80 | 'above' => [ 81 | new VersionRange('1', '2'), 82 | '4', 83 | false, 84 | ], 85 | ]; 86 | } 87 | 88 | /** 89 | * @dataProvider provideIsIncludedCases 90 | */ 91 | public function testIsIncluded(VersionRange $versionRange, string $version, bool $expected): void 92 | { 93 | $this->assertSame($expected, $versionRange->isIncluded($version)); 94 | } 95 | 96 | public function provideAllowsLowerThanCases(): iterable 97 | { 98 | return [ 99 | 'same null' => [ 100 | new VersionRange(null, null), 101 | new VersionRange(null, null), 102 | false, 103 | ], 104 | 'same value' => [ 105 | new VersionRange('1', null), 106 | new VersionRange('1', null), 107 | false, 108 | ], 109 | 'null is lowest' => [ 110 | new VersionRange(null, null), 111 | new VersionRange('1', null), 112 | true, 113 | ], 114 | 'other null is lowest' => [ 115 | new VersionRange('1', null), 116 | new VersionRange(null, null), 117 | false, 118 | ], 119 | 'first is lower' => [ 120 | new VersionRange('1', null), 121 | new VersionRange('2', null), 122 | true, 123 | ], 124 | 'second is lower' => [ 125 | new VersionRange('2', null), 126 | new VersionRange('1', null), 127 | false, 128 | ], 129 | ]; 130 | } 131 | 132 | /** 133 | * @dataProvider provideAllowsLowerThanCases 134 | */ 135 | public function testAllowsLowerThan(VersionRange $versionRange, VersionRange $other, bool $lower): void 136 | { 137 | $this->assertSame($lower, $versionRange->allowsLowerThan($other)); 138 | } 139 | 140 | public function provideAllowsHigherThanCases(): iterable 141 | { 142 | return [ 143 | 'same null' => [ 144 | new VersionRange(null, null), 145 | new VersionRange(null, null), 146 | false, 147 | ], 148 | 'same value' => [ 149 | new VersionRange(null, '1'), 150 | new VersionRange(null, '1'), 151 | false, 152 | ], 153 | 'null is highest' => [ 154 | new VersionRange(null, null), 155 | new VersionRange(null, '1'), 156 | false, 157 | ], 158 | 'other null is highest' => [ 159 | new VersionRange(null, '1'), 160 | new VersionRange(null, null), 161 | true, 162 | ], 163 | 'second value is higher' => [ 164 | new VersionRange(null, '1'), 165 | new VersionRange(null, '2'), 166 | false, 167 | ], 168 | 'first value is higher' => [ 169 | new VersionRange(null, '2'), 170 | new VersionRange(null, '1'), 171 | true, 172 | ], 173 | ]; 174 | } 175 | 176 | /** 177 | * @dataProvider provideAllowsHigherThanCases 178 | */ 179 | public function testAllowsHigherThan(VersionRange $versionRange, VersionRange $other, bool $higher): void 180 | { 181 | $this->assertSame($higher, $versionRange->allowsHigherThan($other)); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /tests/ModelParser/Fixtures/IntersectionTypeDeclarationModel.php: -------------------------------------------------------------------------------- 1 | parser->parse($classMetadata); 28 | 29 | $props = $classMetadata->getPropertyCollections(); 30 | $this->assertCount(1, $props, 'Number of properties should match'); 31 | 32 | $this->assertPropertyCollection('property', 1, $props[0]); 33 | $this->assertPropertyVariation('property', false, true, $props[0]->getVariations()[0]); 34 | } 35 | 36 | public function testAttributes(): void 37 | { 38 | $c = new class() { 39 | #[JMS\Type('string')] 40 | private $property1; 41 | 42 | #[JMS\Type('bool')] 43 | public $property2; 44 | }; 45 | 46 | $classMetadata = new RawClassMetadata(\get_class($c)); 47 | $this->parser->parse($classMetadata); 48 | 49 | $props = $classMetadata->getPropertyCollections(); 50 | $this->assertCount(2, $props, 'Number of properties should match'); 51 | 52 | $this->assertPropertyCollection('property1', 1, $props[0]); 53 | $property = $props[0]->getVariations()[0]; 54 | $this->assertPropertyVariation('property1', false, false, $property); 55 | $this->assertPropertyType(PropertyTypePrimitive::class, 'string|null', true, $property->getType()); 56 | 57 | $this->assertPropertyCollection('property2', 1, $props[1]); 58 | $property = $props[1]->getVariations()[0]; 59 | $this->assertPropertyVariation('property2', true, false, $property); 60 | $this->assertPropertyType(PropertyTypePrimitive::class, 'bool|null', true, $property->getType()); 61 | } 62 | 63 | public function testAttributesMixedWithAnnotations(): void 64 | { 65 | $c = new class() { 66 | /** 67 | * @JMS\SerializedName("property_mixed") 68 | * 69 | * @JMS\Groups({"group1"}) 70 | */ 71 | #[JMS\Type('string')] 72 | private $mixedProperty; 73 | 74 | #[JMS\SerializedName('property_attribute')] 75 | #[JMS\Type('bool')] 76 | public $attributeProperty; 77 | 78 | /** 79 | * @JMS\Type("array") 80 | */ 81 | public $annotationsProperty; 82 | }; 83 | 84 | $classMetadata = new RawClassMetadata(\get_class($c)); 85 | $this->parser->parse($classMetadata); 86 | 87 | $props = $classMetadata->getPropertyCollections(); 88 | $this->assertCount(3, $props, 'Number of properties should match'); 89 | 90 | $this->assertPropertyCollection('property_mixed', 1, $props[0]); 91 | $property = $props[0]->getVariations()[0]; 92 | $this->assertPropertyVariation('mixedProperty', false, false, $property); 93 | $this->assertPropertyType(PropertyTypePrimitive::class, 'string|null', true, $property->getType()); 94 | $this->assertSame(['group1'], $props[0]->getVariations()[0]->getGroups()); 95 | 96 | $this->assertPropertyCollection('property_attribute', 1, $props[1]); 97 | $property = $props[1]->getVariations()[0]; 98 | $this->assertPropertyVariation('attributeProperty', true, false, $property); 99 | $this->assertPropertyType(PropertyTypePrimitive::class, 'bool|null', true, $property->getType()); 100 | 101 | $this->assertPropertyCollection('annotations_property', 1, $props[2]); 102 | $property = $props[2]->getVariations()[0]; 103 | $this->assertPropertyVariation('annotationsProperty', true, false, $property); 104 | $this->assertPropertyType(PropertyTypeIterable::class, 'string[]|null', true, $property->getType()); 105 | } 106 | 107 | public function testVirtualPropertyWithoutDocblock(): void 108 | { 109 | $c = new class() { 110 | #[JMS\VirtualProperty] 111 | public function foo(): string 112 | { 113 | return 'bar'; 114 | } 115 | }; 116 | 117 | $classMetadata = new RawClassMetadata(\get_class($c)); 118 | $this->parser->parse($classMetadata); 119 | 120 | $props = $classMetadata->getPropertyCollections(); 121 | $this->assertCount(1, $props, 'Number of properties should match'); 122 | 123 | $this->assertPropertyCollection('foo', 1, $props[0]); 124 | $property = $props[0]->getVariations()[0]; 125 | $this->assertPropertyVariation('foo', true, true, $property); 126 | $this->assertPropertyType(PropertyTypePrimitive::class, 'string', false, $property->getType()); 127 | $this->assertPropertyAccessor('foo', null, $property->getAccessor()); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /tests/ModelParser/JMSParserTestLegacy.php: -------------------------------------------------------------------------------- 1 | parser->parse($classMetadata); 26 | 27 | $props = $classMetadata->getPropertyCollections(); 28 | $this->assertCount(1, $props, 'Number of properties should match'); 29 | 30 | $this->assertPropertyCollection('property', 1, $props[0]); 31 | $this->assertPropertyVariation('property', false, true, $props[0]->getVariations()[0]); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/ModelParser/Model/AbstractModel.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | private $collectionNamespace; 32 | } 33 | -------------------------------------------------------------------------------- /tests/ModelParser/ParserContextTest.php: -------------------------------------------------------------------------------- 1 | assertStringContainsString('Root', $s); 22 | $this->assertStringNotContainsString('property1', $s); 23 | $this->assertStringNotContainsString('property2', $s); 24 | } 25 | 26 | public function testPush(): void 27 | { 28 | $context = new ParserContext('Root'); 29 | $context = $context->push(new PropertyVariationMetadata('property1', true, true)); 30 | $context = $context->push(new PropertyVariationMetadata('property2', false, true)); 31 | 32 | $s = (string) $context; 33 | $this->assertStringContainsString('Root', $s); 34 | $this->assertStringContainsString('property1', $s); 35 | $this->assertStringContainsString('property2', $s); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/ModelParser/RawMetadata/RawClassMetadataTest.php: -------------------------------------------------------------------------------- 1 | addPropertyVariation('test', new PropertyVariationMetadata('testProperty', true, false)); 20 | $collection = $rawClassMetadata->getPropertyCollection('test'); 21 | 22 | $this->assertCount(1, $collection->getVariations()); 23 | $this->assertSame('test', $collection->getSerializedName()); 24 | 25 | $rawClassMetadata->renameProperty('test', 'new_name'); 26 | $this->assertFalse($rawClassMetadata->hasPropertyCollection('test')); 27 | $this->assertTrue($rawClassMetadata->hasPropertyCollection('new_name')); 28 | $collection = $rawClassMetadata->getPropertyCollection('new_name'); 29 | $this->assertCount(1, $collection->getVariations()); 30 | $this->assertSame('new_name', $collection->getSerializedName()); 31 | 32 | $this->assertTrue($collection->hasVariation('testProperty')); 33 | $variation = $collection->getVariation('testProperty'); 34 | $this->assertSame('testProperty', $variation->getName()); 35 | } 36 | 37 | public function testRenameMerge(): void 38 | { 39 | $rawClassMetadata = new RawClassMetadata('Foo'); 40 | $rawClassMetadata->addPropertyVariation('testProperty', new PropertyVariationMetadata('testProperty', true, false)); 41 | $rawClassMetadata->addPropertyVariation('test', new PropertyVariationMetadata('test', true, false)); 42 | 43 | $this->assertTrue($rawClassMetadata->hasPropertyCollection('testProperty')); 44 | $this->assertTrue($rawClassMetadata->hasPropertyCollection('test')); 45 | 46 | $rawClassMetadata->renameProperty('testProperty', 'test'); 47 | $this->assertFalse($rawClassMetadata->hasPropertyCollection('testProperty')); 48 | $this->assertTrue($rawClassMetadata->hasPropertyCollection('test')); 49 | $collection = $rawClassMetadata->getPropertyCollection('test'); 50 | $this->assertCount(2, $collection->getVariations()); 51 | $this->assertSame('test', $collection->getSerializedName()); 52 | 53 | $this->assertTrue($collection->hasVariation('testProperty')); 54 | $variation = $collection->getVariation('testProperty'); 55 | $this->assertSame('testProperty', $variation->getName()); 56 | $this->assertTrue($collection->hasVariation('test')); 57 | $variation = $collection->getVariation('test'); 58 | $this->assertSame('test', $variation->getName()); 59 | } 60 | 61 | public function testRemove(): void 62 | { 63 | $rawClassMetadata = new RawClassMetadata('Foo'); 64 | $rawClassMetadata->addPropertyVariation('test', new PropertyVariationMetadata('testProperty', true, false)); 65 | 66 | $this->assertCount(1, $rawClassMetadata->getPropertyCollections()); 67 | $this->assertTrue($rawClassMetadata->hasPropertyVariation('testProperty')); 68 | $this->assertTrue($rawClassMetadata->hasPropertyCollection('test')); 69 | 70 | $rawClassMetadata->removePropertyVariation('testProperty'); 71 | 72 | $this->assertCount(0, $rawClassMetadata->getPropertyCollections()); 73 | $this->assertFalse($rawClassMetadata->hasPropertyVariation('testProperty')); 74 | $this->assertFalse($rawClassMetadata->hasPropertyCollection('test')); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/ModelParser/VisibilityAwarePropertyAccessGuesserTest.php: -------------------------------------------------------------------------------- 1 | parseClass($class, $parsers); 22 | 23 | $this->assertSame(\get_class($class), $classMetadata->getClassName()); 24 | $this->assertCount($expectedPropertyCount, $classMetadata->getPropertyCollections(), 'Number of properties should match'); 25 | 26 | if (null !== $accessType) { 27 | ['public' => $public, 'hasGetter' => $hasGetter, 'hasSetter' => $hasSetter] = $accessType; 28 | 29 | foreach ($classMetadata->getPropertyVariations() as $propertyVariation) { 30 | $this->assertSame($public, $propertyVariation->isPublic()); 31 | 32 | if (null !== $hasGetter) { 33 | $this->assertSame($hasGetter, $propertyVariation->getAccessor()->hasGetterMethod()); 34 | } 35 | if (null !== $hasSetter) { 36 | $this->assertSame($hasSetter, $propertyVariation->getAccessor()->hasSetterMethod()); 37 | } 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * @param class-string|object $class 44 | * @param ModelParserInterface[] $parsers 45 | */ 46 | public function parseClass($class, array $parsers): RawClassMetadata 47 | { 48 | $class = \is_object($class) ? \get_class($class) : $class; 49 | $classMetadata = new RawClassMetadata($class); 50 | 51 | foreach ($parsers as $parser) { 52 | $parser->parse($classMetadata); 53 | } 54 | 55 | return $classMetadata; 56 | } 57 | 58 | /** 59 | * @return Generator 69 | */ 70 | public static function provideClassesTests(): \Generator 71 | { 72 | yield 'NoPredecessor' => [ 73 | 'class' => new class() { 74 | public ?string $name = 'php'; 75 | }, 76 | 'parsers' => [ 77 | new VisibilityAwarePropertyAccessGuesser(), 78 | ], 79 | 'expectedPropertyCount' => 0, 80 | 'accessType' => null, 81 | ]; 82 | yield 'Empty' => [ 83 | 'class' => new class() { 84 | }, 85 | 'parsers' => [ 86 | new ReflectionParser(), 87 | new VisibilityAwarePropertyAccessGuesser(), 88 | ], 89 | 'expectedPropertyCount' => 0, 90 | 'accessType' => null, 91 | ]; 92 | yield 'SinglePublic' => [ 93 | 'class' => new class() { 94 | public ?string $name = 'php'; 95 | }, 96 | 'parsers' => [ 97 | new ReflectionParser(), 98 | new VisibilityAwarePropertyAccessGuesser(), 99 | ], 100 | 'expectedPropertyCount' => 1, 101 | 'accessType' => [ 102 | 'public' => true, 103 | 'hasGetter' => null, 104 | 'hasSetter' => null, 105 | ], 106 | ]; 107 | yield 'SinglePrivate' => [ 108 | 'class' => new class() { 109 | private ?string $name = 'php'; 110 | 111 | public function getName(): ?string 112 | { 113 | return $this->name; 114 | } 115 | 116 | public function setName(?string $name): void 117 | { 118 | $this->name = $name; 119 | } 120 | }, 121 | 'parsers' => [ 122 | new ReflectionParser(), 123 | new VisibilityAwarePropertyAccessGuesser(), 124 | ], 125 | 'expectedPropertyCount' => 1, 126 | 'accessType' => [ 127 | 'public' => false, 128 | 'hasGetter' => true, 129 | 'hasSetter' => true, 130 | ], 131 | ]; 132 | yield 'MissingGetter' => [ 133 | 'class' => new class() { 134 | private ?string $name; 135 | 136 | public function setName(?string $name): void 137 | { 138 | $this->name = $name; 139 | } 140 | }, 141 | 'parsers' => [ 142 | new ReflectionParser(), 143 | new VisibilityAwarePropertyAccessGuesser(), 144 | ], 145 | 'expectedPropertyCount' => 1, 146 | 'accessType' => [ 147 | 'public' => false, 148 | 'hasGetter' => false, 149 | 'hasSetter' => true, 150 | ], 151 | ]; 152 | yield 'MissingSetter' => [ 153 | 'class' => new class() { 154 | private ?string $name = 'php'; 155 | 156 | public function getName(): ?string 157 | { 158 | return $this->name; 159 | } 160 | }, 161 | 'parsers' => [ 162 | new ReflectionParser(), 163 | new VisibilityAwarePropertyAccessGuesser(), 164 | ], 165 | 'expectedPropertyCount' => 1, 166 | 'accessType' => [ 167 | 'public' => false, 168 | 'hasGetter' => true, 169 | 'hasSetter' => false, 170 | ], 171 | ]; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /tests/ParserTest.php: -------------------------------------------------------------------------------- 1 | parser = new Parser([ 34 | new ReflectionParser(), 35 | new PhpDocParser(), 36 | ]); 37 | } 38 | 39 | public function testUnknownClass(): void 40 | { 41 | $this->expectException(ParseException::class); 42 | $this->parser->parse('__invalid__'); 43 | } 44 | 45 | public function testSimple(): void 46 | { 47 | $c = new class() { 48 | private $property1; 49 | protected $property2; 50 | 51 | /** 52 | * @var string 53 | */ 54 | public $property3; 55 | }; 56 | 57 | $classMetadataList = $this->parser->parse(\get_class($c)); 58 | 59 | $this->assertCount(1, $classMetadataList, 'Number of class metadata should match'); 60 | 61 | $props = $classMetadataList[0]->getPropertyCollections(); 62 | $this->assertCount(3, $props, 'Number of class metadata properties should match'); 63 | 64 | $this->assertPropertyCollection('property1', 1, $props[0]); 65 | $property1 = $props[0]->getVariations()[0]; 66 | $this->assertProperty('property1', false, false, $property1); 67 | $this->assertPropertyType($property1->getType(), PropertyTypeUnknown::class, 'mixed', true); 68 | 69 | $this->assertPropertyCollection('property2', 1, $props[1]); 70 | $property2 = $props[1]->getVariations()[0]; 71 | $this->assertProperty('property2', false, false, $property2); 72 | $this->assertPropertyType($property2->getType(), PropertyTypeUnknown::class, 'mixed', true); 73 | 74 | $this->assertPropertyCollection('property3', 1, $props[2]); 75 | $property3 = $props[2]->getVariations()[0]; 76 | $this->assertProperty('property3', true, false, $property3); 77 | $this->assertPropertyType($property3->getType(), PropertyTypePrimitive::class, 'string', false); 78 | } 79 | 80 | public function testNested(): void 81 | { 82 | $c = new class() { 83 | /** 84 | * @var Nested 85 | */ 86 | private $property; 87 | }; 88 | 89 | $classMetadataList = $this->parser->parse(\get_class($c)); 90 | 91 | $this->assertCount(2, $classMetadataList, 'Number of class metadata should match'); 92 | 93 | // First class 94 | 95 | $props = $classMetadataList[0]->getPropertyCollections(); 96 | $this->assertCount(1, $props, 'Number of class metadata properties should match'); 97 | 98 | $this->assertPropertyCollection('property', 1, $props[0]); 99 | $property = $props[0]->getVariations()[0]; 100 | $this->assertProperty('property', false, false, $property); 101 | $this->assertPropertyType($property->getType(), PropertyTypeClass::class, Nested::class, false); 102 | 103 | // Second class 104 | 105 | $props = $classMetadataList[1]->getPropertyCollections(); 106 | $this->assertCount(1, $props, 'Number of class metadata properties should match'); 107 | 108 | $this->assertPropertyCollection('nested_property', 1, $props[0]); 109 | $property = $props[0]->getVariations()[0]; 110 | $this->assertProperty('nestedProperty', false, false, $property); 111 | $this->assertPropertyType($property->getType(), PropertyTypeUnknown::class, 'mixed', true); 112 | } 113 | 114 | public function testNestedArray(): void 115 | { 116 | $c = new class() { 117 | /** 118 | * @var Nested[] 119 | */ 120 | private $property; 121 | }; 122 | 123 | $classMetadataList = $this->parser->parse(\get_class($c)); 124 | 125 | $this->assertCount(2, $classMetadataList, 'Number of class metadata should match'); 126 | 127 | // First class 128 | 129 | $props = $classMetadataList[0]->getPropertyCollections(); 130 | $this->assertCount(1, $props, 'Number of class metadata properties should match'); 131 | 132 | $this->assertPropertyCollection('property', 1, $props[0]); 133 | $property = $props[0]->getVariations()[0]; 134 | $this->assertProperty('property', false, false, $property); 135 | $this->assertPropertyType($property->getType(), PropertyTypeIterable::class, Nested::class.'[]', false); 136 | $this->assertPropertyType($property->getType()->getSubType(), PropertyTypeClass::class, Nested::class, false); 137 | 138 | // Second class 139 | 140 | $props = $classMetadataList[1]->getPropertyCollections(); 141 | $this->assertCount(1, $props, 'Number of class metadata properties should match'); 142 | 143 | $this->assertPropertyCollection('nested_property', 1, $props[0]); 144 | $property = $props[0]->getVariations()[0]; 145 | $this->assertProperty('nestedProperty', false, false, $property); 146 | $this->assertPropertyType($property->getType(), PropertyTypeUnknown::class, 'mixed', true); 147 | } 148 | 149 | private function assertPropertyCollection(string $serializedName, int $variations, PropertyCollection $prop): void 150 | { 151 | $this->assertSame($serializedName, $prop->getSerializedName(), 'Serialized name of property should match'); 152 | $this->assertCount($variations, $prop->getVariations(), "Number of variations of property {$serializedName} should match"); 153 | } 154 | 155 | private function assertProperty(string $name, bool $public, bool $readOnly, PropertyVariationMetadata $property): void 156 | { 157 | $this->assertSame($name, $property->getName(), 'Name of property should match'); 158 | $this->assertSame($public, $property->isPublic(), "Public flag of property {$name} should match"); 159 | $this->assertSame($readOnly, $property->isReadOnly(), "Read only flag of property {$name} should match"); 160 | } 161 | 162 | private function assertPropertyType(PropertyType $type, string $propertyTypeClass, string $typeString, bool $nullable): void 163 | { 164 | $this->assertInstanceOf($propertyTypeClass, $type); 165 | $this->assertSame($nullable, $type->isNullable()); 166 | $this->assertSame($typeString, (string) $type); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /tests/PropertyReducerTest.php: -------------------------------------------------------------------------------- 1 | assertCount(0, $reduced->getProperties(), 'Number of properties should match'); 29 | $this->assertCount(0, $reduced->getPostDeserializeMethods(), 'Number of post deserialize methods should match'); 30 | $this->assertCount(0, $reduced->getConstructorParameters(), 'Number of constructor parameters should match'); 31 | } 32 | 33 | public function testReduceKeepsPostDeserializeMethods(): void 34 | { 35 | $rawClassMetadata = new RawClassMetadata('Foo'); 36 | $rawClassMetadata->addPostDeserializeMethod('method1'); 37 | $rawClassMetadata->addPostDeserializeMethod('method2'); 38 | 39 | $reduced = PropertyReducer::reduce($rawClassMetadata); 40 | 41 | $this->assertSame(['method1', 'method2'], $reduced->getPostDeserializeMethods()); 42 | } 43 | 44 | public function testReduceKeepsConstructorParameters(): void 45 | { 46 | $rawClassMetadata = new RawClassMetadata('Foo'); 47 | $rawClassMetadata->addConstructorParameter(new ParameterMetadata('param1', true, null)); 48 | $rawClassMetadata->addConstructorParameter(new ParameterMetadata('param2', true, null)); 49 | 50 | $reduced = PropertyReducer::reduce($rawClassMetadata); 51 | 52 | $this->assertCount(2, $reduced->getConstructorParameters(), 'Number of constructor parameters should match'); 53 | } 54 | 55 | public function testReduceSimpleProperties(): void 56 | { 57 | $rawClassMetadata = new RawClassMetadata('Foo'); 58 | $rawClassMetadata->addPropertyVariation('property1', new PropertyVariationMetadata('property1', false, true)); 59 | $rawClassMetadata->addPropertyVariation('property2', new PropertyVariationMetadata('property2', false, true)); 60 | 61 | $reduced = PropertyReducer::reduce($rawClassMetadata); 62 | 63 | $this->assertProperties(['property1', 'property2'], $reduced->getProperties()); 64 | } 65 | 66 | public function testReducePropertiesWithReducers(): void 67 | { 68 | $property1 = new PropertyVariationMetadata('property1', false, true); 69 | $property1->setVersionRange(new VersionRange('1.0', '1.4')); 70 | $property1->setGroups(['group1']); 71 | $property2 = new PropertyVariationMetadata('property2', false, true); 72 | 73 | $rawClassMetadata = new RawClassMetadata('Foo'); 74 | $rawClassMetadata->addPropertyVariation('property', $property1); 75 | $rawClassMetadata->addPropertyVariation('property', $property2); 76 | 77 | $reduced = PropertyReducer::reduce($rawClassMetadata, [ 78 | new VersionReducer('1.0'), 79 | new GroupReducer(['group1']), 80 | new TakeBestReducer(), 81 | ]); 82 | 83 | $this->assertProperties(['property1'], $reduced->getProperties()); 84 | } 85 | 86 | public function testReduceRemovesProperties(): void 87 | { 88 | $property1 = new PropertyVariationMetadata('property1', false, true); 89 | $property2 = new PropertyVariationMetadata('property2', false, true); 90 | $property2->setGroups(['group1']); 91 | $property3 = new PropertyVariationMetadata('property3', false, true); 92 | $property3->setGroups(['group1', 'group2']); 93 | 94 | $rawClassMetadata = new RawClassMetadata('Foo'); 95 | $rawClassMetadata->addPropertyVariation('property1', $property1); 96 | $rawClassMetadata->addPropertyVariation('property2', $property2); 97 | $rawClassMetadata->addPropertyVariation('property3', $property3); 98 | 99 | $reduced = PropertyReducer::reduce($rawClassMetadata, [ 100 | new VersionReducer('1.0'), 101 | new GroupReducer(['group1']), 102 | new TakeBestReducer(), 103 | ]); 104 | 105 | $this->assertProperties(['property2', 'property3'], $reduced->getProperties()); 106 | } 107 | 108 | /** 109 | * @param string[] $propertyNames 110 | * @param PropertyVariationMetadata[] $properties 111 | */ 112 | private function assertProperties(array $propertyNames, iterable $properties): void 113 | { 114 | $names = []; 115 | foreach ($properties as $property) { 116 | $names[] = $property->getName(); 117 | } 118 | 119 | $this->assertSame($propertyNames, $names, 'Properties should match'); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /tests/RecursionContextTest.php: -------------------------------------------------------------------------------- 1 | assertStringContainsString('Root', $s); 22 | $this->assertStringNotContainsString('property1', $s); 23 | $this->assertStringNotContainsString('property2', $s); 24 | } 25 | 26 | public function testPush(): void 27 | { 28 | $context = new RecursionContext('Root'); 29 | $context = $context->push(new PropertyMetadata('property1', 'property1')); 30 | $context = $context->push(new PropertyMetadata('property2', 'property2')); 31 | 32 | $s = (string) $context; 33 | $this->assertStringContainsString('Root', $s); 34 | $this->assertStringContainsString('property1', $s); 35 | $this->assertStringContainsString('property2', $s); 36 | } 37 | 38 | public function testMatchesEmpty(): void 39 | { 40 | $context = new RecursionContext('Root'); 41 | 42 | $this->assertFalse($context->matches([])); 43 | $this->assertFalse($context->matches(['foo', 'bar', 'baz'])); 44 | } 45 | 46 | public function testMatches(): void 47 | { 48 | $context = new RecursionContext('Root'); 49 | $context = $context->push(new PropertyMetadata('property1', 'property1')); 50 | $context = $context->push(new PropertyMetadata('property2', 'property2')); 51 | 52 | $this->assertFalse($context->matches(['Root', 'property2'])); 53 | $this->assertFalse($context->matches(['property2', 'property1'])); 54 | $this->assertTrue($context->matches(['Root', 'property1'])); 55 | $this->assertTrue($context->matches(['Root', 'property1', 'property2'])); 56 | $this->assertTrue($context->matches(['property1', 'property2'])); 57 | $this->assertTrue($context->matches(['property2'])); 58 | } 59 | 60 | public function testMatchesWildcard(): void 61 | { 62 | $context = new RecursionContext('Root'); 63 | $context = $context->push(new PropertyMetadata('property1', 'property1')); 64 | $context = $context->push(new PropertyMetadata('property2', 'property2')); 65 | 66 | $this->assertFalse($context->matches(['Root', '*', 'property1'])); 67 | $this->assertFalse($context->matches(['*', 'property1'])); 68 | $this->assertTrue($context->matches(['Root', '*'])); 69 | $this->assertTrue($context->matches(['Root', '*', 'property2'])); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Reducer/GroupReducerTest.php: -------------------------------------------------------------------------------- 1 | setGroups(['group1', 'group2']); 21 | $property3 = new PropertyVariationMetadata('property3', false, true); 22 | $property3->setGroups(['group3']); 23 | 24 | $properties = [ 25 | $property1, 26 | $property2, 27 | $property3, 28 | ]; 29 | 30 | $reducedProperties = (new GroupReducer(['group1']))->reduce('property', $properties); 31 | $this->assertProperties(['property2'], $reducedProperties); 32 | 33 | $reducedProperties = (new GroupReducer(['group2']))->reduce('property', $properties); 34 | $this->assertProperties(['property2'], $reducedProperties); 35 | 36 | $reducedProperties = (new GroupReducer(['group3']))->reduce('property', $properties); 37 | $this->assertProperties(['property3'], $reducedProperties); 38 | 39 | $reducedProperties = (new GroupReducer(['group1', 'group2', 'group3']))->reduce('property', $properties); 40 | $this->assertProperties(['property2', 'property3'], $reducedProperties); 41 | 42 | $reducedProperties = (new GroupReducer([]))->reduce('property', $properties); 43 | $this->assertProperties(['property1', 'property2', 'property3'], $reducedProperties); 44 | } 45 | 46 | /** 47 | * @param string[] $propertyNames 48 | * @param PropertyVariationMetadata[] $properties 49 | */ 50 | private function assertProperties(array $propertyNames, iterable $properties): void 51 | { 52 | $names = []; 53 | foreach ($properties as $property) { 54 | $names[] = $property->getName(); 55 | } 56 | 57 | $this->assertSame($propertyNames, $names, 'Properties should match'); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Reducer/TakeBestReducerTest.php: -------------------------------------------------------------------------------- 1 | reduce('property', $properties); 24 | $this->assertProperties(['property', 'other'], $reducedProperties); 25 | } 26 | 27 | public function testReduceWithDifferentNames(): void 28 | { 29 | $properties = [ 30 | new PropertyVariationMetadata('other', false, true), 31 | new PropertyVariationMetadata('other2', false, true), 32 | ]; 33 | 34 | $reducedProperties = (new TakeBestReducer())->reduce('property', $properties); 35 | $this->assertProperties(['other', 'other2'], $reducedProperties); 36 | } 37 | 38 | /** 39 | * @param string[] $propertyNames 40 | * @param PropertyVariationMetadata[] $properties 41 | */ 42 | private function assertProperties(array $propertyNames, iterable $properties): void 43 | { 44 | $names = []; 45 | foreach ($properties as $property) { 46 | $names[] = $property->getName(); 47 | } 48 | 49 | $this->assertSame($propertyNames, $names, 'Properties should match'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Reducer/VersionReducerTest.php: -------------------------------------------------------------------------------- 1 | setVersionRange(new VersionRange(null, '1.4')); 21 | $property2 = new PropertyVariationMetadata('property2', false, true); 22 | $property2->setVersionRange(new VersionRange('2.0', '2.2')); 23 | $property3 = new PropertyVariationMetadata('property3', false, true); 24 | $property3->setVersionRange(new VersionRange('3.0', null)); 25 | 26 | $properties = [ 27 | $property1, 28 | $property2, 29 | $property3, 30 | ]; 31 | 32 | $reducedProperties = (new VersionReducer('0.3'))->reduce('property', $properties); 33 | $this->assertProperties(['property1'], $reducedProperties); 34 | 35 | $reducedProperties = (new VersionReducer('1.0'))->reduce('property', $properties); 36 | $this->assertProperties(['property1'], $reducedProperties); 37 | 38 | $reducedProperties = (new VersionReducer('2.0'))->reduce('property', $properties); 39 | $this->assertProperties(['property2'], $reducedProperties); 40 | 41 | $reducedProperties = (new VersionReducer('3.0'))->reduce('property', $properties); 42 | $this->assertProperties(['property3'], $reducedProperties); 43 | 44 | $reducedProperties = (new VersionReducer('4.0'))->reduce('property', $properties); 45 | $this->assertProperties(['property3'], $reducedProperties); 46 | 47 | $reducedProperties = (new VersionReducer('1.9'))->reduce('property', $properties); 48 | $this->assertProperties([], $reducedProperties); 49 | } 50 | 51 | /** 52 | * @param string[] $propertyNames 53 | * @param PropertyVariationMetadata[] $properties 54 | */ 55 | private function assertProperties(array $propertyNames, iterable $properties): void 56 | { 57 | $names = []; 58 | foreach ($properties as $property) { 59 | $names[] = $property->getName(); 60 | } 61 | 62 | $this->assertSame($propertyNames, $names, 'Properties should match'); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/TypeParser/JMSTypeParserTest.php: -------------------------------------------------------------------------------- 1 | parser = new JMSTypeParser(); 25 | } 26 | 27 | public function provideTypeCases(): iterable 28 | { 29 | yield [ 30 | '', 31 | 'mixed', 32 | true, 33 | ]; 34 | 35 | yield [ 36 | 'array', 37 | 'array', 38 | true, 39 | ]; 40 | 41 | yield [ 42 | 'string', 43 | 'string|null', 44 | ]; 45 | 46 | yield [ 47 | 'boolean', 48 | 'bool|null', 49 | ]; 50 | 51 | yield [ 52 | 'integer', 53 | 'int|null', 54 | ]; 55 | 56 | yield [ 57 | 'double', 58 | 'float|null', 59 | ]; 60 | 61 | yield [ 62 | 'stdClass', 63 | 'stdClass|null', 64 | ]; 65 | 66 | yield [ 67 | 'array', 68 | 'string[]|null', 69 | ]; 70 | 71 | yield [ 72 | 'array>>', 73 | 'bool[][][]|null', 74 | ]; 75 | 76 | yield [ 77 | 'array', 78 | 'int[]|null', 79 | ]; 80 | 81 | yield [ 82 | 'array', 83 | 'int[string]|null', 84 | ]; 85 | 86 | yield [ 87 | 'array>>', 88 | 'bool[string][string][string]|null', 89 | ]; 90 | 91 | yield [ 92 | 'array>>', 93 | 'bool[string][][string]|null', 94 | ]; 95 | } 96 | 97 | /** 98 | * @dataProvider provideTypeCases 99 | */ 100 | public function testType(string $rawType, string $expectedType, bool $expectedNullable = null): void 101 | { 102 | $type = $this->parser->parse($rawType); 103 | 104 | $this->assertSame($expectedType, (string) $type, 'Type should match'); 105 | if (null !== $expectedNullable) { 106 | $this->assertSame($expectedNullable, $type->isNullable(), 'Nullable flag should match'); 107 | } 108 | } 109 | 110 | public function provideDateTimeTypeCases(): iterable 111 | { 112 | yield [ 113 | 'DateTime', 114 | 'DateTime|null', 115 | null, 116 | null, 117 | null, 118 | ]; 119 | 120 | yield [ 121 | 'DateTime<\'Y-m-d H:i:s\'>', 122 | 'DateTime|null', 123 | 'Y-m-d H:i:s', 124 | null, 125 | 'Y-m-d H:i:s', 126 | ]; 127 | 128 | yield [ 129 | 'DateTime<\'\', \'Europe/Zurich\'>', 130 | 'DateTime|null', 131 | null, 132 | 'Europe/Zurich', 133 | null, 134 | ]; 135 | 136 | yield [ 137 | 'DateTime<\'\', \'\', \'Y-m-d\'>', 138 | 'DateTime|null', 139 | null, 140 | null, 141 | 'Y-m-d', 142 | ]; 143 | 144 | yield [ 145 | 'DateTime<\'Y-m-d H:i:s\', \'Europe/Zurich\', \'Y-m-d\'>', 146 | 'DateTime|null', 147 | 'Y-m-d H:i:s', 148 | 'Europe/Zurich', 149 | 'Y-m-d', 150 | ]; 151 | 152 | yield [ 153 | 'DateTimeImmutable', 154 | 'DateTimeImmutable|null', 155 | null, 156 | null, 157 | null, 158 | ]; 159 | 160 | yield [ 161 | 'DateTimeImmutable<\'Y-m-d H:i:s\'>', 162 | 'DateTimeImmutable|null', 163 | 'Y-m-d H:i:s', 164 | null, 165 | 'Y-m-d H:i:s', 166 | ]; 167 | 168 | yield [ 169 | 'DateTimeImmutable<\'\', \'Europe/Zurich\'>', 170 | 'DateTimeImmutable|null', 171 | null, 172 | 'Europe/Zurich', 173 | null, 174 | ]; 175 | 176 | yield [ 177 | 'DateTimeImmutable<\'\', \'\', \'Y-m-d\'>', 178 | 'DateTimeImmutable|null', 179 | null, 180 | null, 181 | 'Y-m-d', 182 | ]; 183 | 184 | yield [ 185 | 'DateTimeImmutable<\'Y-m-d H:i:s\', \'Europe/Zurich\', \'Y-m-d\'>', 186 | 'DateTimeImmutable|null', 187 | 'Y-m-d H:i:s', 188 | 'Europe/Zurich', 189 | 'Y-m-d', 190 | ]; 191 | } 192 | 193 | /** 194 | * @dataProvider provideDateTimeTypeCases 195 | */ 196 | public function testDateTimeType(string $rawType, string $expectedType, ?string $expectedFormat, ?string $expectedZone, ?string $expectedDeserializeFormat): void 197 | { 198 | /** @var PropertyTypeDateTime $type */ 199 | $type = $this->parser->parse($rawType); 200 | $this->assertInstanceOf(PropertyTypeDateTime::class, $type); 201 | $this->assertSame($expectedType, (string) $type, 'Type should match'); 202 | $this->assertSame($expectedFormat, $type->getFormat(), 'Date time format should match'); 203 | $this->assertSame($expectedZone, $type->getZone(), 'Date time zone should match'); 204 | $this->assertSame($expectedDeserializeFormat, $type->getDeserializeFormat(), 'Date time deserialize format should match'); 205 | $this->assertSame($expectedDeserializeFormat ? [$expectedDeserializeFormat] : null, $type->getDeserializeFormats(), 'Date time deserialize format should match'); 206 | } 207 | 208 | public function testInvalidTypeWithParameters(): void 209 | { 210 | $this->expectException(InvalidTypeException::class); 211 | $this->parser->parse('stdClass'); 212 | } 213 | 214 | public function testArrayWithTooManyParameters(): void 215 | { 216 | $this->expectException(InvalidTypeException::class); 217 | $this->parser->parse('array'); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /tests/TypeParser/PhpTypeParserTest.php: -------------------------------------------------------------------------------- 1 | parser = new PhpTypeParser(); 29 | } 30 | 31 | public function providePropertyTypeCases(): iterable 32 | { 33 | yield [ 34 | '', 35 | 'mixed', 36 | true, 37 | ]; 38 | 39 | yield [ 40 | 'mixed', 41 | 'mixed', 42 | true, 43 | ]; 44 | 45 | yield [ 46 | 'object', 47 | 'mixed', 48 | false, 49 | ]; 50 | 51 | yield [ 52 | 'array', 53 | 'array', 54 | false, 55 | ]; 56 | 57 | yield [ 58 | 'string', 59 | 'string', 60 | ]; 61 | 62 | yield [ 63 | 'boolean', 64 | 'bool', 65 | ]; 66 | 67 | yield [ 68 | 'integer', 69 | 'int', 70 | ]; 71 | 72 | yield [ 73 | 'double', 74 | 'float', 75 | ]; 76 | 77 | yield [ 78 | 'int|null', 79 | 'int|null', 80 | ]; 81 | 82 | yield [ 83 | '\stdClass|null', 84 | 'stdClass|null', 85 | ]; 86 | 87 | yield [ 88 | '\DateTime', 89 | 'DateTime', 90 | ]; 91 | 92 | yield [ 93 | '\DateTimeImmutable', 94 | 'DateTimeImmutable', 95 | ]; 96 | 97 | yield [ 98 | 'string[]', 99 | 'string[]', 100 | ]; 101 | 102 | yield [ 103 | 'string[][][]', 104 | 'string[][][]', 105 | ]; 106 | 107 | yield [ 108 | 'string[string]', 109 | 'string[string]', 110 | ]; 111 | 112 | yield [ 113 | 'string[string][string][string]', 114 | 'string[string][string][string]', 115 | ]; 116 | 117 | yield [ 118 | 'string[][string][]', 119 | 'string[][string][]', 120 | ]; 121 | 122 | yield [ 123 | '\stdClass[]|null', 124 | 'stdClass[]|null', 125 | ]; 126 | 127 | yield [ 128 | 'string[]|\Doctrine\Common\Collections\Collection|null', 129 | 'string[]|\Doctrine\Common\Collections\Collection|null', 130 | ]; 131 | 132 | yield [ 133 | '\stdClass[][string]', 134 | 'stdClass[][string]', 135 | ]; 136 | } 137 | 138 | public function providePropertyTypeArrayIsCollectionCases(): iterable 139 | { 140 | yield [ 141 | 'string[]|\Doctrine\Common\Collections\Collection', 142 | ]; 143 | 144 | yield [ 145 | 'string[]|\Doctrine\Common\Collections\ArrayCollection', 146 | ]; 147 | } 148 | 149 | /** 150 | * @dataProvider providePropertyTypeCases 151 | */ 152 | public function testPropertyType(string $rawType, string $expectedType, bool $expectedNullable = null): void 153 | { 154 | $type = $this->parser->parseAnnotationType($rawType, new \ReflectionClass($this)); 155 | 156 | $this->assertSame($expectedType, (string) $type, 'Type should match'); 157 | if (null !== $expectedNullable) { 158 | $this->assertSame($expectedNullable, $type->isNullable(), 'Nullable flag should match'); 159 | } 160 | } 161 | 162 | /** 163 | * @dataProvider providePropertyTypeArrayIsCollectionCases 164 | */ 165 | public function testPropertyTypeArrayIsCollection(string $rawType): void 166 | { 167 | $type = $this->parser->parseAnnotationType($rawType, new \ReflectionClass($this)); 168 | self::assertInstanceOf(PropertyTypeIterable::class, $type); 169 | self::assertTrue($type->isTraversable()); 170 | } 171 | 172 | public function testMultiType(): void 173 | { 174 | $this->expectException(InvalidTypeException::class); 175 | $this->parser->parseAnnotationType('string|int', new \ReflectionClass($this)); 176 | } 177 | 178 | public function testResourceType(): void 179 | { 180 | $this->expectException(InvalidTypeException::class); 181 | $this->parser->parseAnnotationType('resource', new \ReflectionClass($this)); 182 | } 183 | 184 | public function provideNamespaceResolutionCases(): iterable 185 | { 186 | yield [ 187 | 'ReflectionAbstractModel', 188 | ReflectionAbstractModel::class, 189 | ]; 190 | 191 | yield [ 192 | 'ReflectionBaseModel', 193 | RecursionContextTest::class, 194 | ]; 195 | 196 | yield [ 197 | 'Nested', 198 | BaseModel::class, 199 | ]; 200 | 201 | yield [ 202 | 'Nested[string][]', 203 | BaseModel::class.'[string][]', 204 | ]; 205 | 206 | yield [ 207 | 'Nested[]|Collection', 208 | BaseModel::class.'[]|\Doctrine\Common\Collections\Collection<'.BaseModel::class.'>', 209 | ]; 210 | } 211 | 212 | /** 213 | * @dataProvider provideNamespaceResolutionCases 214 | */ 215 | public function testNamespaceResolution(string $rawType, string $expectedType): void 216 | { 217 | $type = $this->parser->parseAnnotationType($rawType, new \ReflectionClass(WithImports::class)); 218 | 219 | $this->assertSame($expectedType, (string) $type, 'Type should match'); 220 | } 221 | 222 | public function provideReflectionTypeCases(): iterable 223 | { 224 | $c = new class() { 225 | private function method1(): string 226 | { 227 | return '1'; 228 | } 229 | 230 | private function method2(): ?int 231 | { 232 | return 1; 233 | } 234 | 235 | private function method3(): array 236 | { 237 | return [1]; 238 | } 239 | }; 240 | $reflClass = new \ReflectionClass(\get_class($c)); 241 | 242 | yield [ 243 | $reflClass->getMethod('method1')->getReturnType(), 244 | 'string', 245 | ]; 246 | 247 | yield [ 248 | $reflClass->getMethod('method2')->getReturnType(), 249 | 'int|null', 250 | ]; 251 | 252 | yield [ 253 | $reflClass->getMethod('method3')->getReturnType(), 254 | 'array', 255 | false, 256 | ]; 257 | } 258 | 259 | /** 260 | * @dataProvider provideReflectionTypeCases 261 | */ 262 | public function testReflectionType(\ReflectionType $reflType, string $expectedType, bool $expectedNullable = null): void 263 | { 264 | $type = $this->parser->parseReflectionType($reflType); 265 | 266 | $this->assertSame($expectedType, (string) $type, 'Type should match'); 267 | if (null !== $expectedNullable) { 268 | $this->assertSame($expectedNullable, $type->isNullable(), 'Nullable flag should match'); 269 | } 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | add('JMS\Serializer\Tests', __DIR__); 11 | })(); 12 | --------------------------------------------------------------------------------