├── .github └── workflows │ ├── ci.yml │ └── static.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── composer.json └── src ├── Compiler.php ├── Configuration ├── ClassToGenerate.php ├── GeneratorConfiguration.php └── GroupCombination.php ├── Context.php ├── DeserializerGenerator.php ├── Exception ├── Exception.php ├── UnsupportedFormatException.php └── UnsupportedTypeException.php ├── Path ├── AbstractEntry.php ├── ArrayEntry.php ├── ArrayPath.php ├── ModelEntry.php ├── ModelPath.php └── Root.php ├── Recursion.php ├── Serializer.php ├── SerializerGenerator.php ├── SerializerInterface.php └── Template ├── Deserialization.php └── Serialization.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: ['8.0', '8.1', '8.2', '8.3'] 18 | include: 19 | - php-version: '8.0' 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 rector/rector 30 | - name: Composer cache 31 | uses: actions/cache@v3 32 | with: 33 | path: ${{ env.HOME }}/.composer/cache 34 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.json') }} 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 | tools: phpstan 23 | - name: Get composer cache directory 24 | id: composer-cache 25 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 26 | - name: Cache dependencies 27 | uses: actions/cache@v3 28 | with: 29 | path: ${{ steps.composer-cache.outputs.dir }} 30 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 31 | restore-keys: ${{ runner.os }}-composer- 32 | - name: Install dependencies 33 | run: composer update --prefer-dist --no-interaction 34 | - name: PHPStan 35 | run: composer phpstan-all 36 | 37 | cs: 38 | name: "CS Fixer" 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Check out code into the workspace 42 | uses: actions/checkout@v3 43 | - name: Setup PHP 8.2 44 | uses: shivammathur/setup-php@v2 45 | with: 46 | php-version: 8.2 47 | - name: Get composer cache directory 48 | id: composer-cache 49 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 50 | - name: Cache dependencies 51 | uses: actions/cache@v3 52 | with: 53 | path: ${{ steps.composer-cache.outputs.dir }} 54 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 55 | restore-keys: ${{ runner.os }}-composer- 56 | - name: Install dependencies 57 | run: composer update --prefer-dist --no-interaction 58 | - name: CS Fixer 59 | run: composer cs:check 60 | 61 | rector: 62 | name: "Rector" 63 | runs-on: ubuntu-latest 64 | steps: 65 | - name: Check out code into the workspace 66 | uses: actions/checkout@v3 67 | - name: Setup PHP 8.2 68 | uses: shivammathur/setup-php@v2 69 | with: 70 | php-version: 8.2 71 | - name: Get composer cache directory 72 | id: composer-cache 73 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 74 | - name: Cache dependencies 75 | uses: actions/cache@v3 76 | with: 77 | path: ${{ steps.composer-cache.outputs.dir }} 78 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 79 | restore-keys: ${{ runner.os }}-composer- 80 | - name: Install dependencies 81 | run: composer update --prefer-dist --no-interaction 82 | - name: Rector PHP 83 | run: composer rector:check 84 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | # 2.6.1 4 | 5 | * Fixed: Dates with a getter/setter where incorrectly handled in the refactoring for 2.6.0. 6 | See (#44)[https://github.com/liip/serializer/pull/44] 7 | * Add support for symfony `7.x` 8 | * Also test against PHP `8.3` 9 | * Update rector to `1.2.1` 10 | 11 | # 2.6.0 12 | 13 | * (De)serialization now accepts timezones, and lists of deserialization formats 14 | 15 | # 2.5.1 16 | 17 | * Generalized the improvement on arrays with primitive types to generate more efficient code. 18 | 19 | # 2.5.0 20 | 21 | * Clean CI workflow: fix GitHub composer caches 22 | * Add rector-php analysis 23 | * Increase Phpstan check level to 7 24 | * Fix fallback to JMS serializer when the order of configured groups were 25 | not ordered as the ones in the generated PHP filenames. 26 | Keep consistent sorting on both Context and GroupCombination classes. 27 | * Fix bug when serializing a multidimensional array with a primitive type. 28 | 29 | # 2.4.0 30 | 31 | * Increase liip/metadata to `1.1` and drop support for `0.6` 32 | * Clean up build process 33 | 34 | # 2.3.1 35 | 36 | * Allow installation with liip/metadata 1.x in addition to 0.6 37 | 38 | # 2.3.0 39 | 40 | * Fixed deprecation warnings for PHP 8 41 | * Dropped support for PHP 7 42 | 43 | # 2.2.0 44 | 45 | * Add new parameter `$options` to the `GenerateConfiguration` class 46 | * Support (de)serializing arrays with undefined content by setting the 47 | `allow_generic_arrays` option to `true`. 48 | 49 | # 2.1.0 50 | 51 | * Add support for generating recursive code up to a specified maximum depth 52 | that can be defined via the `@MaxDepth` annotation/attribute from JMS 53 | * Add support for (de-)serializing doctrine collections 54 | 55 | # 2.0.6 56 | 57 | * Allow installation with liip/metadata-parser 0.5 58 | * Test with PHP 8.1 59 | 60 | # 2.0.5 61 | 62 | * Allow installation with liip/metadata-parser 0.4 and Symfony 6 63 | 64 | # 2.0.4 65 | 66 | * Support PHP 8 67 | * Allow installation with liip/metadata-parser 0.3 and jms/serializer 3 68 | 69 | # 2.0.3 70 | 71 | * [DX]: Context now removes duplicates in groups. 72 | * [DX]: Better exception message for unknown constructor argument. 73 | 74 | # 2.0.2 75 | 76 | * [Bugfix]: Respect group configuration when no version is specified. 77 | 78 | # 2.0.1 79 | 80 | * [Bugfix]: Fix deserialization of DateTime with a format. 81 | 82 | # 2.0.0 83 | 84 | * [BC Break]: Configuration of the serializer generator changed to configuration model. 85 | The new format allows to more precisely specify which serializers to generate. 86 | 87 | # 1.0.0 88 | 89 | Initial release 90 | -------------------------------------------------------------------------------- /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 Liip Serializer, 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 | Liip Serializer 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 Serializer - A fast JSON serializer 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. We plan to add more documentation and support, including Symfony bundles in the near future. If there is anything that you need or have questions about we would love to see you open an issue! :)** 4 | 5 | # What it supports 6 | This serializer can convert between JSON and PHP objects and back. It uses reflection, Phpdoc and [JMS Serializer](https://github.com/schmittjoh/serializer/) annotations to generate PHP code for the conversion. JMS serializer groups and versions are supported for serializing but not for deserializing. 7 | 8 | ## Limitations 9 | If you customized JMS Serializer with your own listeners or similar things, this serializer will not work for you. We made an effort to detect when unsupported features are used and raise an error, but recommend that you double check whether the Liip Serializer really produces the exact same as JMS when transforming your data. 10 | 11 | # How it works 12 | The Liip Serializer generates PHP code based on the PHP models that you specify. It uses the flexible `liip/metadata-parser` to gather metadata on the models. A separate file is generated for every version and serializer groups combination to move all logic to the code generation step. This serializer is fast even for massive object trees because the generated PHP code is very simplistic and specific to the usecase, rather than the complex, flexible callback structure that JMS serializer uses. The project we developped this for often has data with up to a megabyte of compact JSON data. 13 | 14 | You can use the Liip Serializer stand alone. If you are already working with 15 | JMS Serializer, you can also use the drop-in replacement for JMS serializer 16 | [liip/serializer-jms-adapter](https://github.com/liip/serializer-jms-adapter). 17 | The drop-in adapter implements the JMS interfaces and provides fallback to the 18 | regular JMS serializer for missing generated files and on other errors. 19 | 20 | # How to use it 21 | You need to generate converter files whenever your models change. They follow a 22 | naming scheme that allows the Liip Serializer to find them. Because the files 23 | have to be pre-generated, you need to specify the exact list of classes, 24 | serializer groups and versions you want to support. 25 | 26 | Note: We plan to create a Symfony bundle to integrate the Liip Serializer into 27 | Symfony. 28 | 29 | ## Generate your files 30 | This step needs to be executed during the deployment phase and whenever your 31 | models change. 32 | 33 | ```php 34 | use Doctrine\Common\Annotations\AnnotationReader; 35 | use Liip\MetadataParser\Builder; 36 | use Liip\MetadataParser\Parser; 37 | use Liip\MetadataParser\RecursionChecker; 38 | use Liip\MetadataParser\ModelParser\JMSParser; 39 | use Liip\MetadataParser\ModelParser\LiipMetadataAnnotationParser; 40 | use Liip\MetadataParser\ModelParser\PhpDocParser; 41 | use Liip\MetadataParser\ModelParser\ReflectionParser; 42 | use Liip\Serializer\DeserializerGenerator; 43 | use Liip\Serializer\Serializer; 44 | use Liip\Serializer\SerializerGenerator; 45 | use Liip\Serializer\Template\Deserialization; 46 | use Liip\Serializer\Template\Serialization; 47 | 48 | $configuration = GeneratorConfiguration::createFomArray([ 49 | 'options' => [ 50 | 'allow_generic_arrays' => false, 51 | ], 52 | 'default_group_combinations' => ['api'], 53 | 'default_versions' => ['', '1', '2'], 54 | 'classes' => [ 55 | Product::class => [ 56 | 'default_versions' => ['1', '2'], // optional, falls back to global list 57 | 'group_combinations' => [ // optional, falls back to global default_group_combinations 58 | [ 59 | 'groups' => [], // generate without groups 60 | ], 61 | [ 62 | 'groups' => ['api'], // global groups are overwritten, not merged. versions are taken from class default 63 | ], 64 | [ 65 | 'groups' => ['api', 'detail'], 66 | 'versions' => ['2'], // only generate the combination of api and detail for version 2 67 | ], 68 | ], 69 | ], 70 | Other::class => [], // generate this class with default groups and versions 71 | ] 72 | ]); 73 | 74 | $parsers = [ 75 | new ReflectionParser(), 76 | new PhpDocParser(), 77 | new JMSParser(new AnnotationReader()), 78 | new LiipMetadataAnnotationParser(new AnnotationReader()), 79 | ]; 80 | $builder = new Builder(new Parser($parsers), new RecursionChecker(null, [])); 81 | 82 | $serializerGenerator = new SerializerGenerator( new Serialization(), $configuration, $cacheDirectory); 83 | $deserializerGenerator = new DeserializerGenerator(new Deserialization(), [Product::class, User::class], $cacheDirectory); 84 | $serializerGenerator->generate($builder); 85 | $deserializerGenerator->generate($builder); 86 | ``` 87 | 88 | ### Configuration format 89 | 90 | Specify global defaults for the versions to be generated, and the group 91 | combinations. 92 | 93 | Then specify for which classes to generate the serializer and deserializer. 94 | 95 | For each class, you can overwrite which versions to generate. If you specify 96 | no group combinations, the global default group combinations are used. If you 97 | specify group combinations, you can again overwrite the versions to generate. 98 | 99 | Note that defaults are not merged - the most specific list is used exclusively. 100 | 101 | #### Version 102 | 103 | To generate a file without version, specify version `''` in the list of versions. 104 | 105 | #### Groups 106 | 107 | To generate a serializer without groups, specify an empty group combination `[]`. 108 | 109 | ### Arrays with an unknown type 110 | 111 | If you want to (de)serialize arrays with undefined content, you can do that by 112 | setting the `allow_generic_arrays` value to `true` via the `options` argument. 113 | 114 | Note: This will only work if the contents of that array are only primitive 115 | types (string, int, float, boolean and nested arrays with only these types). 116 | 117 | ## Serialize using the generated code 118 | In this example, we serialize an object of class `Product` for version 2: 119 | 120 | ```php 121 | use Acme\Model\Product; 122 | use Liip\Serializer\Context; 123 | use Liip\Serializer\Serializer; 124 | 125 | $serializer = new Serializer($cacheDirectory); 126 | 127 | // A model to serialize 128 | $productModel = new Product(); 129 | 130 | // Your serialized data 131 | $data = $serializer->serialize($productModel, 'json', (new Context())->setVersion(2)); 132 | ``` 133 | 134 | ## Deserialize using the generated code 135 | ```php 136 | use Acme\Model\Product; 137 | use Liip\Serializer\Serializer; 138 | 139 | $serializer = new Serializer($cacheDirectory); 140 | 141 | // Data to deserialize 142 | $data = '{ 143 | "api_string": "api", 144 | "detail_string": "details", 145 | "nested_field": { 146 | "nested_string": "nested" 147 | }, 148 | "date": "2018-08-03T00:00:00+02:00", 149 | "date_immutable": "2016-06-01T00:00:00+02:00" 150 | }'; 151 | 152 | /** @var Product $model */ 153 | $model = $serializer->deserialize($data, Product::class, 'json'); 154 | ``` 155 | 156 | ## Working with Arrays 157 | 158 | Like JMS Serializer, the Liip Serializer also provides `fromArray` and 159 | `toArray` for working with array data. As usual when using PHP arrays for JSON 160 | data, you will lose the distinction between empty array and empty object. 161 | 162 | # Where do I go for help? 163 | If you need help, please open an issue on github. 164 | 165 | # Background: Why an Object Serializer Generator? 166 | We started having performance problem with large object structures (often 167 | several Megabyte of JSON data). Code analysis showed that a lot of time is 168 | spent calling the JMS callback structure hundred thousands of times. 169 | Simplistic generated PHP code is much more efficient at runtime. 170 | 171 | # Implementation Notes 172 | The `DeserializerGenerator` and `SerializerGenerator` produce PHP code from the 173 | metadata. The generators use twig to render the PHP code, for better 174 | readability. See the `Template` namespace. 175 | 176 | The indentation in the generated code is not respecting levels of nesting. We 177 | could carry around the depth and prepend whitespace, but apart from debugging, 178 | nobody will look at the generated code. 179 | 180 | We decided to not use reflection, for better performance. Properties need to be 181 | public or have a public getter for serialization. For deserialization, we also 182 | match constructor arguments by name, so as long as a non-public property name 183 | matches a constructor argument, it needs no setter. 184 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "liip/serializer", 3 | "description": "High performance serializer that works with code generated helpers to achieve high throughput.", 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/serializer", 14 | "issues": "https://github.com/liip/serializer/issues" 15 | }, 16 | "require": { 17 | "php": "^8.0", 18 | "ext-json": "*", 19 | "liip/metadata-parser": "^1.2", 20 | "pnz/json-exception": "^1.0", 21 | "symfony/filesystem": "^4.4 || ^5.0 || ^6.0 || ^7.0", 22 | "symfony/finder": "^4.4 || ^5.0 || ^6.0 || ^7.0", 23 | "symfony/options-resolver": "^4.4 || ^5.0 || ^6.0 || ^7.0", 24 | "twig/twig": "^2.7 || ^3.0" 25 | }, 26 | "require-dev": { 27 | "doctrine/collections": "^1.6", 28 | "friendsofphp/php-cs-fixer": "^3.23", 29 | "jms/serializer": "^1.13 || ^2 || ^3", 30 | "phpstan/phpstan": "^1.0", 31 | "phpstan/phpstan-phpunit": "^1.3", 32 | "phpunit/phpunit": "^9.6", 33 | "rector/rector": "^1.2.1" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Liip\\Serializer\\": "src/" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Tests\\Liip\\Serializer\\": "tests/" 43 | } 44 | }, 45 | "scripts": { 46 | "cs:check": "vendor/bin/php-cs-fixer fix --dry-run --diff -v", 47 | "cs:fix": "vendor/bin/php-cs-fixer fix -v", 48 | "phpstan": "vendor/bin/phpstan analyse --no-progress --level 7 src/", 49 | "phpstan-tests": "vendor/bin/phpstan analyse --no-progress --level 1 -c phpstan.tests.neon tests/", 50 | "rector:check": "vendor/bin/rector process --dry-run", 51 | "rector:fix": "vendor/bin/rector process", 52 | "phpstan-all": [ 53 | "@phpstan", 54 | "@phpstan-tests" 55 | ], 56 | "phpunit": "vendor/bin/phpunit", 57 | "ci": [ 58 | "@cs:check", 59 | "@rector:check", 60 | "@phpstan-all", 61 | "@phpunit" 62 | ] 63 | }, 64 | "config": { 65 | "sort-packages": true 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Compiler.php: -------------------------------------------------------------------------------- 1 | serializerGenerator->generate($this->metadataBuilder); 21 | $this->deserializerGenerator->generate($this->metadataBuilder); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Configuration/ClassToGenerate.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class ClassToGenerate implements \IteratorAggregate 11 | { 12 | /** 13 | * A list of group combinations, potentially with a version overwrite. 14 | * 15 | * @see GroupCombination::$groups 16 | * 17 | * @var GroupCombination[] 18 | */ 19 | private array $groupCombinations = []; 20 | 21 | /** 22 | * Overwrite global default list of versions to generate. 23 | * 24 | * @see GroupCombination::$versions 25 | * 26 | * @var list|null 27 | */ 28 | private ?array $defaultVersions; 29 | 30 | /** 31 | * @param class-string $className 32 | * @param list|null $defaultVersions 33 | */ 34 | public function __construct( 35 | private GeneratorConfiguration $configuration, 36 | private string $className, 37 | ?array $defaultVersions = null 38 | ) { 39 | $this->defaultVersions = null === $defaultVersions ? null : array_map('strval', $defaultVersions); 40 | } 41 | 42 | public function getClassName(): string 43 | { 44 | return $this->className; 45 | } 46 | 47 | /** 48 | * @return list 49 | */ 50 | public function getDefaultVersions(): array 51 | { 52 | if (null !== $this->defaultVersions) { 53 | return $this->defaultVersions; 54 | } 55 | 56 | return $this->configuration->getDefaultVersions(); 57 | } 58 | 59 | public function addGroupCombination(GroupCombination $groupCombination): void 60 | { 61 | $this->groupCombinations[] = $groupCombination; 62 | } 63 | 64 | public function getIterator(): \Traversable 65 | { 66 | if ($this->groupCombinations) { 67 | return new \ArrayIterator($this->groupCombinations); 68 | } 69 | 70 | return new \ArrayIterator($this->configuration->getDefaultGroupCombinations($this)); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Configuration/GeneratorConfiguration.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class GeneratorConfiguration implements \IteratorAggregate 20 | { 21 | /** 22 | * A list of group combinations. 23 | * 24 | * @see GroupCombination::$groups 25 | * 26 | * @var list> 27 | */ 28 | private array $defaultGroupCombinations; 29 | 30 | /** 31 | * List of versions to generate. An empty string '' means to generate without a version. 32 | * e.g. ['', '2', '3'] 33 | * 34 | * @var list 35 | */ 36 | private array $defaultVersions; 37 | 38 | /** 39 | * @var ClassToGenerate[] 40 | */ 41 | private array $classesToGenerate = []; 42 | 43 | /** 44 | * @var array 45 | */ 46 | private array $options; 47 | 48 | /** 49 | * @param list> $defaultGroupCombinations 50 | * @param list $defaultVersions 51 | * @param array $options 52 | */ 53 | public function __construct(array $defaultGroupCombinations, array $defaultVersions, array $options = []) 54 | { 55 | $this->defaultGroupCombinations = $defaultGroupCombinations ?: [[]]; 56 | $this->defaultVersions = array_map('strval', $defaultVersions) ?: ['']; 57 | $this->options = $this->resolveOptions($options); 58 | } 59 | 60 | /** 61 | * @param array{ 62 | * 'default_group_combinations'?: list>|null, 63 | * 'default_versions'?: list|null, 64 | * 'classes'?: ?array>, 65 | * 'options'?: array>, 66 | * } $config 67 | * 68 | * Create configuration from array definition 69 | * 70 | * [ 71 | * 'options' => [ 72 | * 'allow_generic_arrays' => true, 73 | * ], 74 | * 'default_group_combinations' => [['api']], 75 | * 'default_versions' => ['', '1', '2'], 76 | * 'classes' => [ 77 | * Product::class => [ 78 | * 'default_versions' => ['1', '2'], // optional, falls back to global list 79 | * 'group_combinations' => [ // optional, falls back to global default_group_combinations 80 | * [ 81 | * 'groups' => [], // generate without groups 82 | * ], 83 | * [ 84 | * 'groups' => ['api'], // global groups are overwritten, not merged. versions are taken from class default 85 | * ], 86 | * [ 87 | * 'groups' => ['api', 'detail'], 88 | * 'versions' => ['2'], // only generate the combination of api and detail for version 2 89 | * ], 90 | * ], 91 | * ], 92 | * Other::class => [], // generate this class with default groups and versions 93 | * ] 94 | * ] 95 | */ 96 | public static function createFomArray(array $config): self 97 | { 98 | if (!\array_key_exists('classes', $config) || (is_countable($config['classes']) ? \count($config['classes']) : 0) < 1) { 99 | throw new \InvalidArgumentException('You need to specify the classes to generate'); 100 | } 101 | 102 | $instance = new self( 103 | $config['default_group_combinations'] ?? [], 104 | $config['default_versions'] ?? [], 105 | $config['options'] ?? [] 106 | ); 107 | 108 | foreach ($config['classes'] as $className => $classConfig) { 109 | $classToGenerate = new ClassToGenerate($instance, $className, $classConfig['default_versions'] ?? null); 110 | foreach ($classConfig['group_combinations'] ?? [] as $groupCombination) { 111 | $classToGenerate->addGroupCombination( 112 | new GroupCombination($classToGenerate, $groupCombination['groups'], $groupCombination['versions'] ?? null) 113 | ); 114 | } 115 | $instance->addClassToGenerate($classToGenerate); 116 | } 117 | 118 | return $instance; 119 | } 120 | 121 | public function addClassToGenerate(ClassToGenerate $classToGenerate): void 122 | { 123 | $this->classesToGenerate[] = $classToGenerate; 124 | } 125 | 126 | /** 127 | * @return string[] 128 | */ 129 | public function getDefaultVersions(): array 130 | { 131 | return $this->defaultVersions; 132 | } 133 | 134 | /** 135 | * @return list 136 | */ 137 | public function getDefaultGroupCombinations(ClassToGenerate $classToGenerate): array 138 | { 139 | return array_map( 140 | static fn (array $combination): GroupCombination => new GroupCombination($classToGenerate, $combination), 141 | $this->defaultGroupCombinations 142 | ); 143 | } 144 | 145 | /** 146 | * If this is false, arrays with sub type PropertyTypeUnknown are treated as error. 147 | * If this is true, deserialize assigns the raw array and serialize just takes the raw content of the field. 148 | */ 149 | public function shouldAllowGenericArrays(): bool 150 | { 151 | return $this->options['allow_generic_arrays']; 152 | } 153 | 154 | public function getIterator(): \Traversable 155 | { 156 | return new \ArrayIterator($this->classesToGenerate); 157 | } 158 | 159 | /** 160 | * @param array $options 161 | * 162 | * @return array 163 | */ 164 | private function resolveOptions(array $options): array 165 | { 166 | $resolver = new OptionsResolver(); 167 | $resolver->setDefaults([ 168 | 'allow_generic_arrays' => false, 169 | ]); 170 | 171 | $resolver->setAllowedTypes('allow_generic_arrays', 'boolean'); 172 | 173 | return $resolver->resolve($options); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Configuration/GroupCombination.php: -------------------------------------------------------------------------------- 1 | |null $versions 11 | */ 12 | public function __construct( 13 | private ClassToGenerate $containingClass, 14 | /** 15 | * @var list One combination of groups to generate. 16 | * An empty array means to generate with no groups. 17 | * e.g. ['api', 'details']. 18 | */ 19 | private array $groups, 20 | 21 | /** 22 | * List of versions to generate. 23 | * 24 | * An empty string '' means to generate without a version. 25 | * e.g. ['', '2', '3'] 26 | * 27 | * If not specified, this falls back to the class default. 28 | * If the array is not null, it must have a length > 0. 29 | */ 30 | private ?array $versions = null 31 | ) { 32 | sort($this->groups); 33 | 34 | if (null !== $versions && 0 === \count($versions)) { 35 | throw new \InvalidArgumentException('Version list may not be empty. To generate without version, specify an empty string. To use the default versions, pass null.'); 36 | } 37 | } 38 | 39 | /** 40 | * @return string[] 41 | */ 42 | public function getGroups(): array 43 | { 44 | return $this->groups; 45 | } 46 | 47 | /** 48 | * @return string[] 49 | */ 50 | public function getVersions(): array 51 | { 52 | if (null !== $this->versions) { 53 | return $this->versions; 54 | } 55 | 56 | return $this->containingClass->getDefaultVersions(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Context.php: -------------------------------------------------------------------------------- 1 | groups; 29 | } 30 | 31 | /** 32 | * @param string[] $groups 33 | */ 34 | public function setGroups(array $groups): self 35 | { 36 | $this->groups = array_unique($groups); 37 | sort($this->groups); 38 | 39 | return $this; 40 | } 41 | 42 | public function getVersion(): ?string 43 | { 44 | return $this->version; 45 | } 46 | 47 | public function setVersion(string $version): self 48 | { 49 | $this->version = $version; 50 | 51 | return $this; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/DeserializerGenerator.php: -------------------------------------------------------------------------------- 1 | $classesToGenerate This is a list of FQCN classnames 33 | */ 34 | public function __construct( 35 | private Deserialization $templating, 36 | array $classesToGenerate, 37 | private string $cacheDirectory, 38 | ?GeneratorConfiguration $configuration = null 39 | ) { 40 | $this->filesystem = new Filesystem(); 41 | $this->configuration = $this->createGeneratorConfiguration($configuration, $classesToGenerate); 42 | } 43 | 44 | public static function buildDeserializerFunctionName(string $className): string 45 | { 46 | return self::FILENAME_PREFIX.'_'.str_replace('\\', '_', $className); 47 | } 48 | 49 | public function generate(Builder $metadataBuilder): void 50 | { 51 | $this->filesystem->mkdir($this->cacheDirectory); 52 | 53 | /** @var ClassToGenerate $classToGenerate */ 54 | foreach ($this->configuration as $classToGenerate) { 55 | // we do not use the oldest version reducer here and hope for the best 56 | // otherwise we end up with generated property names for accessor methods 57 | $classMetadata = $metadataBuilder->build($classToGenerate->getClassName(), [ 58 | new TakeBestReducer(), 59 | ]); 60 | $this->writeFile($classMetadata); 61 | } 62 | } 63 | 64 | private function writeFile(ClassMetadata $classMetadata): void 65 | { 66 | if (\count($classMetadata->getConstructorParameters())) { 67 | throw new \Exception(sprintf('We currently do not support deserializing when the root class has a non-empty constructor. Class %s', $classMetadata->getClassName())); 68 | } 69 | 70 | $functionName = self::buildDeserializerFunctionName($classMetadata->getClassName()); 71 | $arrayPath = new ArrayPath('jsonData'); 72 | 73 | $code = $this->templating->renderFunction( 74 | $functionName, 75 | $classMetadata->getClassName(), 76 | (string) $arrayPath, 77 | $this->generateCodeForClass($classMetadata, $arrayPath, new ModelPath('model')) 78 | ); 79 | 80 | $this->filesystem->dumpFile(sprintf('%s/%s.php', $this->cacheDirectory, $functionName), $code); 81 | } 82 | 83 | /** 84 | * @param array $stack 85 | */ 86 | private function generateCodeForClass( 87 | ClassMetadata $classMetadata, 88 | ArrayPath $arrayPath, 89 | ModelPath $modelPath, 90 | array $stack = [] 91 | ): string { 92 | $stack[$classMetadata->getClassName()] = ($stack[$classMetadata->getClassName()] ?? 0) + 1; 93 | 94 | $constructorArgumentNames = []; 95 | $overwrittenNames = []; 96 | $initCode = ''; 97 | $code = ''; 98 | foreach ($classMetadata->getProperties() as $propertyMetadata) { 99 | $propertyArrayPath = $arrayPath->withFieldName($propertyMetadata->getSerializedName()); 100 | 101 | if ($classMetadata->hasConstructorParameter($propertyMetadata->getName())) { 102 | $argument = $classMetadata->getConstructorParameter($propertyMetadata->getName()); 103 | $default = var_export($argument->isRequired() ? null : $argument->getDefaultValue(), true); 104 | $tempVariable = ModelPath::tempVariable([(string) $modelPath, $propertyMetadata->getName()]); 105 | if (\array_key_exists($propertyMetadata->getName(), $constructorArgumentNames)) { 106 | $overwrittenNames[$propertyMetadata->getName()] = true; 107 | } 108 | $constructorArgumentNames[$propertyMetadata->getName()] = (string) $tempVariable; 109 | 110 | $initCode .= $this->templating->renderArgument( 111 | (string) $tempVariable, 112 | $default, 113 | $this->generateCodeForField($propertyMetadata, $propertyArrayPath, $tempVariable, $stack) 114 | ); 115 | } else { 116 | $code .= $this->generateCodeForProperty($propertyMetadata, $propertyArrayPath, $modelPath, $stack); 117 | } 118 | } 119 | 120 | foreach ($classMetadata->getPostDeserializeMethods() as $method) { 121 | $code .= $this->templating->renderPostMethod((string) $modelPath, $method); 122 | } 123 | 124 | $constructorArguments = []; 125 | foreach ($classMetadata->getConstructorParameters() as $definition) { 126 | if (\array_key_exists($definition->getName(), $constructorArgumentNames)) { 127 | $constructorArguments[] = $constructorArgumentNames[$definition->getName()]; 128 | continue; 129 | } 130 | if ($definition->isRequired()) { 131 | $msg = sprintf('Unknown constructor argument "%s". Class %s only has properties that tell how to handle %s.', $definition->getName(), $classMetadata->getClassName(), implode(', ', array_keys($constructorArgumentNames))); 132 | if ($overwrittenNames) { 133 | $msg .= sprintf(' Multiple definitions for fields %s seen - the last one overwrites previous ones.', implode(', ', array_keys($overwrittenNames))); 134 | } 135 | throw new \Exception($msg); 136 | } 137 | $constructorArguments[] = var_export($definition->getDefaultValue(), true); 138 | } 139 | if (\count($constructorArgumentNames) > 0) { 140 | $code .= $this->templating->renderUnset(array_values($constructorArgumentNames)); 141 | } 142 | 143 | return $this->templating->renderClass((string) $modelPath, $classMetadata->getClassName(), $constructorArguments, $code, $initCode); 144 | } 145 | 146 | /** 147 | * @param array $stack 148 | */ 149 | private function generateCodeForProperty( 150 | PropertyMetadata $propertyMetadata, 151 | ArrayPath $arrayPath, 152 | ModelPath $modelPath, 153 | array $stack 154 | ): string { 155 | if ($propertyMetadata->isReadOnly()) { 156 | return ''; 157 | } 158 | 159 | if (Recursion::hasMaxDepthReached($propertyMetadata, $stack)) { 160 | return ''; 161 | } 162 | 163 | if ($propertyMetadata->getAccessor()->hasSetterMethod()) { 164 | $tempVariable = ModelPath::tempVariable([(string) $modelPath, $propertyMetadata->getName()]); 165 | $code = $this->generateCodeForField($propertyMetadata, $arrayPath, $tempVariable, $stack); 166 | $code .= $this->templating->renderConditional( 167 | (string) $tempVariable, 168 | $this->templating->renderSetter((string) $modelPath, $propertyMetadata->getAccessor()->getSetterMethod(), (string) $tempVariable) 169 | ); 170 | $code .= $this->templating->renderUnset([(string) $tempVariable]); 171 | 172 | return $code; 173 | } 174 | 175 | $modelPropertyPath = $modelPath->withPath($propertyMetadata->getName()); 176 | 177 | return $this->generateCodeForField($propertyMetadata, $arrayPath, $modelPropertyPath, $stack); 178 | } 179 | 180 | /** 181 | * @param array $stack 182 | */ 183 | private function generateCodeForField( 184 | PropertyMetadata $propertyMetadata, 185 | ArrayPath $arrayPath, 186 | ModelPath $modelPath, 187 | array $stack 188 | ): string { 189 | return $this->templating->renderConditional( 190 | (string) $arrayPath, 191 | $this->generateInnerCodeForFieldType($propertyMetadata, $arrayPath, $modelPath, $stack) 192 | ); 193 | } 194 | 195 | /** 196 | * @param array $stack 197 | */ 198 | private function generateInnerCodeForFieldType( 199 | PropertyMetadata $propertyMetadata, 200 | ArrayPath $arrayPath, 201 | ModelPath $modelPropertyPath, 202 | array $stack 203 | ): string { 204 | $type = $propertyMetadata->getType(); 205 | 206 | switch ($type) { 207 | case $type instanceof PropertyTypeArray: 208 | if ($type->isTraversable()) { 209 | return $this->generateCodeForArrayCollection($propertyMetadata, $type, $arrayPath, $modelPropertyPath, $stack); 210 | } 211 | 212 | return $this->generateCodeForArray($type, $arrayPath, $modelPropertyPath, $stack); 213 | 214 | case $type instanceof PropertyTypeDateTime: 215 | $formats = $type->getDeserializeFormats() ?: (\is_string($type->getFormat()) ? [$type->getFormat()] : $type->getFormat()); 216 | if (null !== $formats) { 217 | return $this->templating->renderAssignDateTimeFromFormat($type->isImmutable(), (string) $modelPropertyPath, (string) $arrayPath, $formats, $type->getZone()); 218 | } 219 | 220 | return $this->templating->renderAssignDateTimeToField($type->isImmutable(), (string) $modelPropertyPath, (string) $arrayPath); 221 | 222 | case $type instanceof PropertyTypePrimitive && 'float' === $type->getTypeName(): 223 | return $this->templating->renderAssignJsonDataToFieldWithCasting((string) $modelPropertyPath, (string) $arrayPath, 'float'); 224 | 225 | case $type instanceof PropertyTypePrimitive: 226 | case $type instanceof PropertyTypeUnknown: 227 | return $this->templating->renderAssignJsonDataToField((string) $modelPropertyPath, (string) $arrayPath); 228 | 229 | case $type instanceof PropertyTypeClass: 230 | return $this->generateCodeForClass($type->getClassMetadata(), $arrayPath, $modelPropertyPath, $stack); 231 | 232 | default: 233 | throw new \Exception('Unexpected type '.$type::class.' at '.$modelPropertyPath); 234 | } 235 | } 236 | 237 | /** 238 | * @param array $stack 239 | */ 240 | private function generateCodeForArray( 241 | PropertyTypeArray $type, 242 | ArrayPath $arrayPath, 243 | ModelPath $modelPath, 244 | array $stack 245 | ): string { 246 | if ($type->getSubType() instanceof PropertyTypePrimitive) { 247 | // for arrays of scalars, copy the field even when its an empty array 248 | return $this->templating->renderAssignJsonDataToField((string) $modelPath, (string) $arrayPath); 249 | } 250 | 251 | $index = ModelPath::indexVariable((string) $arrayPath); 252 | $arrayPropertyPath = $arrayPath->withVariable((string) $index); 253 | $modelPropertyPath = $modelPath->withArray((string) $index); 254 | $subType = $type->getSubType(); 255 | 256 | switch ($subType) { 257 | case $subType instanceof PropertyTypeArray: 258 | $innerCode = $this->generateCodeForArray($subType, $arrayPropertyPath, $modelPropertyPath, $stack); 259 | break; 260 | 261 | case $subType instanceof PropertyTypeClass: 262 | $innerCode = $this->generateCodeForClass($subType->getClassMetadata(), $arrayPropertyPath, $modelPropertyPath, $stack); 263 | break; 264 | 265 | case $subType instanceof PropertyTypeUnknown && $this->configuration->shouldAllowGenericArrays(): 266 | return $this->templating->renderAssignJsonDataToField((string) $modelPath, (string) $arrayPath); 267 | 268 | default: 269 | throw new \Exception('Unexpected array subtype '.$subType::class); 270 | } 271 | 272 | if ('' === $innerCode) { 273 | return ''; 274 | } 275 | 276 | $code = $this->templating->renderInitArray((string) $modelPath); 277 | $code .= $this->templating->renderLoop((string) $arrayPath, (string) $index, $innerCode); 278 | 279 | return $code; 280 | } 281 | 282 | /** 283 | * @param array $stack 284 | */ 285 | private function generateCodeForArrayCollection( 286 | PropertyMetadata $propertyMetadata, 287 | PropertyTypeArray $type, 288 | ArrayPath $arrayPath, 289 | ModelPath $modelPath, 290 | array $stack 291 | ): string { 292 | $tmpVariable = ModelPath::tempVariable([(string) $modelPath, $propertyMetadata->getName()]); 293 | $innerCode = $this->generateCodeForArray($type, $arrayPath, $tmpVariable, $stack); 294 | 295 | if ('' === $innerCode) { 296 | return ''; 297 | } 298 | 299 | return $innerCode.$this->templating->renderArrayCollection((string) $modelPath, (string) $tmpVariable); 300 | } 301 | 302 | /** 303 | * @param list $classesToGenerate 304 | */ 305 | private function createGeneratorConfiguration( 306 | ?GeneratorConfiguration $configuration, 307 | array $classesToGenerate 308 | ): GeneratorConfiguration { 309 | if (null === $configuration) { 310 | $configuration = new GeneratorConfiguration([], []); 311 | } 312 | 313 | foreach ($classesToGenerate as $className) { 314 | $configuration->addClassToGenerate(new ClassToGenerate($configuration, $className)); 315 | } 316 | 317 | return $configuration; 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/Exception/Exception.php: -------------------------------------------------------------------------------- 1 | $groups 26 | */ 27 | public static function typeUnsupportedSerialization(string $type, ?string $version, array $groups): self 28 | { 29 | $versionInfo = $version ?: '[no version]'; 30 | $groupInfo = \count($groups) ? implode(', ', $groups) : '[no groups]'; 31 | 32 | return new self(sprintf(self::UNSUPPORTED_TYPE_SERIALIZATION, $type, $versionInfo, $groupInfo)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Path/AbstractEntry.php: -------------------------------------------------------------------------------- 1 | path; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Path/ArrayEntry.php: -------------------------------------------------------------------------------- 1 | getPath().']'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Path/ArrayPath.php: -------------------------------------------------------------------------------- 1 | path = [new Root($root)]; 20 | } 21 | 22 | public function __toString(): string 23 | { 24 | return implode('', $this->path); 25 | } 26 | 27 | public function withFieldName(string $component): self 28 | { 29 | $clone = clone $this; 30 | $clone->path[] = new ArrayEntry('\''.$component.'\''); 31 | 32 | return $clone; 33 | } 34 | 35 | public function withVariable(string $component): self 36 | { 37 | $clone = clone $this; 38 | $clone->path[] = new ArrayEntry($component); 39 | 40 | return $clone; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Path/ModelEntry.php: -------------------------------------------------------------------------------- 1 | '.$this->getPath(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Path/ModelPath.php: -------------------------------------------------------------------------------- 1 | property1[$index]->property2, used for code generation. 9 | */ 10 | final class ModelPath implements \Stringable 11 | { 12 | /** 13 | * @var AbstractEntry[] 14 | */ 15 | private array $path = []; 16 | 17 | public function __construct(string $root) 18 | { 19 | $this->path = [new Root($root)]; 20 | } 21 | 22 | public function __toString(): string 23 | { 24 | return implode('', $this->path); 25 | } 26 | 27 | /** 28 | * @param string[] $components 29 | */ 30 | public static function tempVariable(array $components): self 31 | { 32 | $components = array_map( 33 | static fn (string $component): string => ucfirst(str_replace(['->', '[', ']', '$'], '', $component)), 34 | $components 35 | ); 36 | 37 | return new self(lcfirst(implode('', $components))); 38 | } 39 | 40 | public static function indexVariable(string $path): self 41 | { 42 | return new self('index'.mb_strlen($path)); 43 | } 44 | 45 | public function withPath(string $component): self 46 | { 47 | $clone = clone $this; 48 | $clone->path[] = new ModelEntry($component); 49 | 50 | return $clone; 51 | } 52 | 53 | public function withArray(string $component): self 54 | { 55 | $clone = clone $this; 56 | $clone->path[] = new ArrayEntry($component); 57 | 58 | return $clone; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Path/Root.php: -------------------------------------------------------------------------------- 1 | getPath(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Recursion.php: -------------------------------------------------------------------------------- 1 | $stack 15 | */ 16 | public static function check(string $className, array $stack, string $modelPath): bool 17 | { 18 | if (\array_key_exists($className, $stack) && $stack[$className] > 1) { 19 | throw new \Exception(sprintf('recursion for %s at %s', key($stack), $modelPath)); 20 | } 21 | 22 | return false; 23 | } 24 | 25 | /** 26 | * @param array $stack 27 | */ 28 | public static function hasMaxDepthReached(PropertyMetadata $propertyMetadata, array $stack): bool 29 | { 30 | if (null === $propertyMetadata->getMaxDepth()) { 31 | return false; 32 | } 33 | 34 | $className = self::getClassNameFromProperty($propertyMetadata); 35 | if (null === $className) { 36 | return false; 37 | } 38 | 39 | $classStackCount = $stack[$className] ?? 0; 40 | 41 | return $classStackCount > $propertyMetadata->getMaxDepth(); 42 | } 43 | 44 | private static function getClassNameFromProperty(PropertyMetadata $propertyMetadata): ?string 45 | { 46 | $type = $propertyMetadata->getType(); 47 | if ($type instanceof PropertyTypeArray) { 48 | $type = $type->getLeafType(); 49 | } 50 | 51 | if (!($type instanceof PropertyTypeClass)) { 52 | return null; 53 | } 54 | 55 | return $type->getClassName(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Serializer.php: -------------------------------------------------------------------------------- 1 | objectToArray($data, true, $context), \JSON_UNESCAPED_SLASHES); 39 | } catch (\JsonException $e) { 40 | throw new Exception(sprintf('Failed to JSON encode data for %s. This is not supposed to happen.', get_debug_type($data)), 0, $e); 41 | } 42 | } 43 | 44 | /** 45 | * {@inheritdoc} 46 | * 47 | * Version or groups are currently not implemented for deserialization and 48 | * passing a context with one of those values set will lead to an Exception. 49 | */ 50 | public function deserialize(string $data, string $type, string $format, ?Context $context = null): mixed 51 | { 52 | if ('json' !== $format) { 53 | throw new UnsupportedFormatException('Liip serializer only supports JSON for now'); 54 | } 55 | 56 | try { 57 | $array = Json::decode($data, true); 58 | } catch (\JsonException $e) { 59 | throw new Exception('Failed to JSON decode data. This is not supposed to happen.', 0, $e); 60 | } 61 | 62 | return $this->arrayToObject($array, $type, $context); 63 | } 64 | 65 | /** 66 | * {@inheritdoc} 67 | * 68 | * Serializing primitive types is not currently implemented and will lead 69 | * to an UnsupportedTypeException. 70 | */ 71 | public function toArray($data, ?Context $context = null): array 72 | { 73 | return $this->objectToArray($data, false, $context); 74 | } 75 | 76 | /** 77 | * {@inheritdoc} 78 | * 79 | * Version or groups are currently not implemented for deserialization and 80 | * passing a context with one of those values set will lead to an Exception. 81 | */ 82 | public function fromArray(array $data, string $type, ?Context $context = null): mixed 83 | { 84 | return $this->arrayToObject($data, $type, $context); 85 | } 86 | 87 | /** 88 | * @param mixed[] $data 89 | */ 90 | private function arrayToObject(array $data, string $type, ?Context $context): mixed 91 | { 92 | if ($context && ($context->getVersion() || \count($context->getGroups()))) { 93 | throw new Exception('Version and group support is not implemented for deserialization. It is only supported for serialization'); 94 | } 95 | 96 | $functionName = DeserializerGenerator::buildDeserializerFunctionName($type); 97 | $filename = sprintf('%s/%s.php', $this->cacheDirectory, $functionName); 98 | if (!file_exists($filename)) { 99 | throw UnsupportedTypeException::typeUnsupportedDeserialization($type); 100 | } 101 | require_once $filename; 102 | 103 | if (!\is_callable($functionName)) { 104 | throw new Exception(sprintf('Internal Error: Deserializer for %s in file %s does not have expected function %s', $type, $filename, $functionName)); 105 | } 106 | 107 | try { 108 | return $functionName($data); 109 | } catch (\Throwable $t) { 110 | throw new Exception('Error during deserialization', 0, $t); 111 | } 112 | } 113 | 114 | /** 115 | * @return mixed[] 116 | */ 117 | private function objectToArray(mixed $data, bool $useStdClass, ?Context $context): array 118 | { 119 | if (!\is_object($data)) { 120 | throw new UnsupportedTypeException('The Liip Serializer only works for objects'); 121 | } 122 | $type = $data::class; 123 | $groups = []; 124 | $version = null; 125 | if ($context) { 126 | $groups = $context->getGroups(); 127 | if ($context->getVersion()) { 128 | $version = $context->getVersion(); 129 | } 130 | } 131 | $functionName = SerializerGenerator::buildSerializerFunctionName($type, $version ?: null, $groups); 132 | $filename = sprintf('%s/%s.php', $this->cacheDirectory, $functionName); 133 | if (!file_exists($filename)) { 134 | throw UnsupportedTypeException::typeUnsupportedSerialization($type, $version, $groups); 135 | } 136 | 137 | require_once $filename; 138 | 139 | if (!\is_callable($functionName)) { 140 | throw new Exception(sprintf('Internal Error: Serializer for %s in file %s does not have expected function %s', $type, $filename, $functionName)); 141 | } 142 | 143 | try { 144 | return $functionName($data, $useStdClass); 145 | } catch (\Throwable $t) { 146 | throw new Exception('Error during serialization', 0, $t); 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/SerializerGenerator.php: -------------------------------------------------------------------------------- 1 | filesystem = new Filesystem(); 36 | } 37 | 38 | /** 39 | * @param list $serializerGroups 40 | */ 41 | public static function buildSerializerFunctionName(string $className, ?string $apiVersion, array $serializerGroups): string 42 | { 43 | $functionName = self::FILENAME_PREFIX.'_'.$className; 44 | if (\count($serializerGroups)) { 45 | $functionName .= '_'.implode('_', $serializerGroups); 46 | } 47 | if (null !== $apiVersion) { 48 | $functionName .= '_'.$apiVersion; 49 | } 50 | 51 | return preg_replace('/[^a-zA-Z0-9_]/', '_', $functionName); 52 | } 53 | 54 | public function generate(Builder $metadataBuilder): void 55 | { 56 | $this->filesystem->mkdir($this->cacheDirectory); 57 | 58 | foreach ($this->configuration as $classToGenerate) { 59 | foreach ($classToGenerate as $groupCombination) { 60 | $className = $classToGenerate->getClassName(); 61 | foreach ($groupCombination->getVersions() as $version) { 62 | $groups = $groupCombination->getGroups(); 63 | if ('' === $version) { 64 | if ([] === $groups) { 65 | $metadata = $metadataBuilder->build($className, [ 66 | new PreferredReducer(), 67 | new TakeBestReducer(), 68 | ]); 69 | $this->writeFile($className, null, [], $metadata); 70 | } else { 71 | $metadata = $metadataBuilder->build($className, [ 72 | new GroupReducer($groups), 73 | new PreferredReducer(), 74 | new TakeBestReducer(), 75 | ]); 76 | $this->writeFile($className, null, $groups, $metadata); 77 | } 78 | } else { 79 | $metadata = $metadataBuilder->build($className, [ 80 | new VersionReducer($version), 81 | new GroupReducer($groups), 82 | new TakeBestReducer(), 83 | ]); 84 | $this->writeFile($className, $version, $groups, $metadata); 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | /** 92 | * @param list $serializerGroups 93 | */ 94 | private function writeFile( 95 | string $className, 96 | ?string $apiVersion, 97 | array $serializerGroups, 98 | ClassMetadata $classMetadata 99 | ): void { 100 | $functionName = self::buildSerializerFunctionName($className, $apiVersion, $serializerGroups); 101 | 102 | $code = $this->templating->renderFunction( 103 | $functionName, 104 | $className, 105 | $this->generateCodeForClass($classMetadata, $apiVersion, $serializerGroups, '', '$model') 106 | ); 107 | 108 | $this->filesystem->dumpFile(sprintf('%s/%s.php', $this->cacheDirectory, $functionName), $code); 109 | } 110 | 111 | /** 112 | * @param list $serializerGroups 113 | * @param array $stack 114 | */ 115 | private function generateCodeForClass( 116 | ClassMetadata $classMetadata, 117 | ?string $apiVersion, 118 | array $serializerGroups, 119 | string $arrayPath, 120 | string $modelPath, 121 | array $stack = [] 122 | ): string { 123 | $stack[$classMetadata->getClassName()] = ($stack[$classMetadata->getClassName()] ?? 0) + 1; 124 | 125 | $code = ''; 126 | foreach ($classMetadata->getProperties() as $propertyMetadata) { 127 | $code .= $this->generateCodeForField($propertyMetadata, $apiVersion, $serializerGroups, $arrayPath, $modelPath, $stack); 128 | } 129 | 130 | return $this->templating->renderClass($arrayPath, $code); 131 | } 132 | 133 | /** 134 | * @param list $serializerGroups 135 | * @param array $stack 136 | */ 137 | private function generateCodeForField( 138 | PropertyMetadata $propertyMetadata, 139 | ?string $apiVersion, 140 | array $serializerGroups, 141 | string $arrayPath, 142 | string $modelPath, 143 | array $stack 144 | ): string { 145 | if (Recursion::hasMaxDepthReached($propertyMetadata, $stack)) { 146 | return ''; 147 | } 148 | 149 | $modelPropertyPath = $modelPath.'->'.$propertyMetadata->getName(); 150 | $fieldPath = $arrayPath.'["'.$propertyMetadata->getSerializedName().'"]'; 151 | 152 | if ($propertyMetadata->getAccessor()->hasGetterMethod()) { 153 | $tempVariable = str_replace(['->', '[', ']', '$'], '', $modelPath).ucfirst($propertyMetadata->getName()); 154 | 155 | return $this->templating->renderConditional( 156 | $this->templating->renderTempVariable($tempVariable, $this->templating->renderGetter($modelPath, $propertyMetadata->getAccessor()->getGetterMethod())), 157 | $this->generateCodeForFieldType($propertyMetadata->getType(), $apiVersion, $serializerGroups, $fieldPath, '$'.$tempVariable, $stack) 158 | ); 159 | } 160 | if (!$propertyMetadata->isPublic()) { 161 | throw new \Exception(sprintf('Property %s is not public and no getter has been defined. Stack %s', $modelPropertyPath, var_export($stack, true))); 162 | } 163 | 164 | return $this->templating->renderConditional( 165 | $modelPropertyPath, 166 | $this->generateCodeForFieldType($propertyMetadata->getType(), $apiVersion, $serializerGroups, $fieldPath, $modelPropertyPath, $stack) 167 | ); 168 | } 169 | 170 | /** 171 | * @param list $serializerGroups 172 | * @param array $stack 173 | */ 174 | private function generateCodeForFieldType( 175 | PropertyType $type, 176 | ?string $apiVersion, 177 | array $serializerGroups, 178 | string $fieldPath, 179 | string $modelPropertyPath, 180 | array $stack 181 | ): string { 182 | switch ($type) { 183 | case $type instanceof PropertyTypeDateTime: 184 | $dateFormat = $type->getFormat() ?: \DateTimeInterface::ISO8601; 185 | 186 | return $this->templating->renderAssign( 187 | $fieldPath, 188 | $this->templating->renderDateTime($modelPropertyPath, $dateFormat) 189 | ); 190 | 191 | case $type instanceof PropertyTypePrimitive: 192 | case $type instanceof PropertyTypeUnknown: 193 | // for arrays of scalars, copy the field even when its an empty array 194 | return $this->templating->renderAssign($fieldPath, $modelPropertyPath); 195 | 196 | case $type instanceof PropertyTypeClass: 197 | return $this->generateCodeForClass($type->getClassMetadata(), $apiVersion, $serializerGroups, $fieldPath, $modelPropertyPath, $stack); 198 | 199 | case $type instanceof PropertyTypeArray: 200 | return $this->generateCodeForArray($type, $apiVersion, $serializerGroups, $fieldPath, $modelPropertyPath, $stack); 201 | 202 | default: 203 | throw new \Exception('Unexpected type '.$type::class.' at '.$modelPropertyPath); 204 | } 205 | } 206 | 207 | /** 208 | * @param list $serializerGroups 209 | * @param array $stack 210 | */ 211 | private function generateCodeForArray( 212 | PropertyTypeArray $type, 213 | ?string $apiVersion, 214 | array $serializerGroups, 215 | string $arrayPath, 216 | string $modelPath, 217 | array $stack 218 | ): string { 219 | $index = '$index'.mb_strlen($arrayPath); 220 | $subType = $type->getSubType(); 221 | 222 | switch ($subType) { 223 | case $subType instanceof PropertyTypePrimitive: 224 | case $subType instanceof PropertyTypeArray && self::isArrayForPrimitive($subType): 225 | case $subType instanceof PropertyTypeUnknown && $this->configuration->shouldAllowGenericArrays(): 226 | return $this->templating->renderArrayAssign($arrayPath, $modelPath); 227 | 228 | case $subType instanceof PropertyTypeArray: 229 | $innerCode = $this->generateCodeForArray($subType, $apiVersion, $serializerGroups, $arrayPath.'['.$index.']', $modelPath.'['.$index.']', $stack); 230 | break; 231 | 232 | case $subType instanceof PropertyTypeClass: 233 | $innerCode = $this->generateCodeForClass($subType->getClassMetadata(), $apiVersion, $serializerGroups, $arrayPath.'['.$index.']', $modelPath.'['.$index.']', $stack); 234 | break; 235 | 236 | default: 237 | throw new \Exception('Unexpected array subtype '.$subType::class); 238 | } 239 | 240 | if ('' === $innerCode) { 241 | if ($type->isHashmap()) { 242 | return $this->templating->renderLoopHashmapEmpty($arrayPath); 243 | } 244 | 245 | return $this->templating->renderLoopArrayEmpty($arrayPath); 246 | } 247 | 248 | if ($type->isHashmap()) { 249 | return $this->templating->renderLoopHashmap($arrayPath, $modelPath, $index, $innerCode); 250 | } 251 | 252 | return $this->templating->renderLoopArray($arrayPath, $modelPath, $index, $innerCode); 253 | } 254 | 255 | private static function isArrayForPrimitive(PropertyTypeArray $type): bool 256 | { 257 | do { 258 | $type = $type->getSubType(); 259 | if ($type instanceof PropertyTypePrimitive) { 260 | return true; 261 | } 262 | } while ($type instanceof PropertyTypeArray); 263 | 264 | return false; 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/SerializerInterface.php: -------------------------------------------------------------------------------- 1 | {{method}}(); 39 | 40 | EOT; 41 | 42 | private const TMPL_CONDITIONAL = <<<'EOT' 43 | if (isset({{data}})) { 44 | {{code}} 45 | } 46 | 47 | EOT; 48 | 49 | private const TMPL_ASSIGN_JSON_DATA_TO_FIELD = <<<'EOT' 50 | {{modelPath}} = {{jsonPath}}; 51 | 52 | EOT; 53 | 54 | private const TMPL_ASSIGN_JSON_DATA_TO_FIELD_CASTING = <<<'EOT' 55 | {{modelPath}} = ({{type}}) {{jsonPath}}; 56 | 57 | EOT; 58 | 59 | private const TMPL_ASSIGN_DATETIME_TO_FIELD = <<<'EOT' 60 | {{modelPath}} = new \DateTime({{jsonPath}}); 61 | 62 | EOT; 63 | 64 | private const TMPL_ASSIGN_DATETIME_FROM_FORMAT = <<<'EOT' 65 | {{date}} = false; 66 | foreach([{{formats|join(', ')}}] as {{format}}) { 67 | if (({{date}} = \DateTime::createFromFormat({{format}}, {{jsonPath}}, {{timezone}}))) { 68 | {{modelPath}} = {{date}}; 69 | break; 70 | } 71 | } 72 | 73 | if (false === {{date}}) { 74 | throw new \Exception('Invalid datetime string '.({{jsonPath}}).' matches none of the deserialization formats: '.{{formatsError}}); 75 | } 76 | unset({{format}}, {{date}}); 77 | 78 | EOT; 79 | 80 | private const TMPL_ASSIGN_DATETIME_IMMUTABLE_TO_FIELD = <<<'EOT' 81 | {{modelPath}} = new \DateTimeImmutable({{jsonPath}}); 82 | 83 | EOT; 84 | 85 | private const TMPL_ASSIGN_DATETIME_IMMUTABLE_FROM_FORMAT = <<<'EOT' 86 | {{date}} = false; 87 | foreach([{{formats|join(', ')}}] as {{format}}) { 88 | if (({{date}} = \DateTimeImmutable::createFromFormat({{format}}, {{jsonPath}}, {{timezone}}))) { 89 | {{modelPath}} = {{date}}; 90 | break; 91 | } 92 | } 93 | 94 | if (false === {{date}}) { 95 | throw new \Exception('Invalid datetime string '.({{jsonPath}}).' matches none of the deserialization formats: '.{{formatsError}}); 96 | } 97 | unset({{format}}, {{date}}); 98 | 99 | EOT; 100 | 101 | private const TMPL_ASSIGN_SETTER = <<<'EOT' 102 | {{modelPath}}->{{method}}({{value}}); 103 | 104 | EOT; 105 | 106 | private const TMPL_INIT_ARRAY = <<<'EOT' 107 | {{modelPath}} = []; 108 | 109 | EOT; 110 | 111 | private const TMPL_LOOP = <<<'EOT' 112 | foreach (array_keys({{jsonPath}}) as {{indexVariable}}) { 113 | {{code}} 114 | } 115 | 116 | EOT; 117 | 118 | private const TMPL_ARRAY_COLLECTION = <<<'EOT' 119 | {{modelPath}} = new \Doctrine\Common\Collections\ArrayCollection({{tmpVariable}}); 120 | 121 | EOT; 122 | 123 | private const TMPL_UNSET = <<<'EOT' 124 | unset({{variableNames|join(', ')}}); 125 | 126 | EOT; 127 | 128 | private const TMPL_EXTRACT = '{{jsonPath}} ?? {{default}}'; 129 | 130 | private const TMPL_CREATE_OBJECT = 'new {{className}}({{arguments|join(\', \')}})'; 131 | 132 | private Environment $twig; 133 | 134 | public function __construct() 135 | { 136 | $this->twig = new Environment(new ArrayLoader(), ['autoescape' => false]); 137 | } 138 | 139 | public function renderFunction(string $name, string $className, string $jsonPath, string $code): string 140 | { 141 | return $this->render(self::TMPL_FUNCTION, [ 142 | 'functionName' => $name, 143 | 'className' => $className, 144 | 'jsonPath' => $jsonPath, 145 | 'code' => $code, 146 | ]); 147 | } 148 | 149 | /** 150 | * @param list $arguments 151 | */ 152 | public function renderClass(string $modelPath, string $className, array $arguments, string $code, string $initArgumentsCode = ''): string 153 | { 154 | return $this->render(self::TMPL_CLASS, [ 155 | 'modelPath' => $modelPath, 156 | 'className' => $className, 157 | 'arguments' => $arguments, 158 | 'code' => $code, 159 | 'initArgumentsCode' => $initArgumentsCode, 160 | ]); 161 | } 162 | 163 | public function renderArgument(string $variableName, string $default, string $code): string 164 | { 165 | return $this->render(self::TMPL_ARGUMENT, [ 166 | 'variableName' => $variableName, 167 | 'default' => $default, 168 | 'code' => $code, 169 | ]); 170 | } 171 | 172 | public function renderPostMethod(string $modelPath, string $method): string 173 | { 174 | return $this->render(self::TMPL_POST_METHOD, [ 175 | 'modelPath' => $modelPath, 176 | 'method' => $method, 177 | ]); 178 | } 179 | 180 | public function renderConditional(string $data, string $code): string 181 | { 182 | return $this->render(self::TMPL_CONDITIONAL, [ 183 | 'data' => $data, 184 | 'code' => $code, 185 | ]); 186 | } 187 | 188 | public function renderAssignJsonDataToField(string $modelPath, string $jsonPath): string 189 | { 190 | return $this->render(self::TMPL_ASSIGN_JSON_DATA_TO_FIELD, [ 191 | 'modelPath' => $modelPath, 192 | 'jsonPath' => $jsonPath, 193 | ]); 194 | } 195 | 196 | public function renderAssignJsonDataToFieldWithCasting(string $modelPath, string $jsonPath, string $type): string 197 | { 198 | return $this->render(self::TMPL_ASSIGN_JSON_DATA_TO_FIELD_CASTING, [ 199 | 'modelPath' => $modelPath, 200 | 'jsonPath' => $jsonPath, 201 | 'type' => $type, 202 | ]); 203 | } 204 | 205 | public function renderAssignDateTimeToField(bool $immutable, string $modelPath, string $jsonPath): string 206 | { 207 | $template = $immutable ? self::TMPL_ASSIGN_DATETIME_IMMUTABLE_TO_FIELD : self::TMPL_ASSIGN_DATETIME_TO_FIELD; 208 | 209 | return $this->render($template, [ 210 | 'modelPath' => $modelPath, 211 | 'jsonPath' => $jsonPath, 212 | ]); 213 | } 214 | 215 | /** 216 | * @param list|string $formats 217 | */ 218 | public function renderAssignDateTimeFromFormat(bool $immutable, string $modelPath, string $jsonPath, array|string $formats, ?string $timezone = null): string 219 | { 220 | if (\is_string($formats)) { 221 | @trigger_error('Passing a string for argument $formats is deprecated, please pass an array of strings instead', \E_USER_DEPRECATED); 222 | $formats = [$formats]; 223 | } 224 | 225 | $template = $immutable ? self::TMPL_ASSIGN_DATETIME_IMMUTABLE_FROM_FORMAT : self::TMPL_ASSIGN_DATETIME_FROM_FORMAT; 226 | $formats = array_map( 227 | static fn (string $f): string => var_export($f, true), 228 | $formats 229 | ); 230 | $formatsError = var_export(implode(',', $formats), true); 231 | $dateVariable = preg_replace_callback( 232 | '/([^a-zA-Z]+|\d+)([a-zA-Z])/', 233 | static fn ($match): string => (ctype_digit($match[1]) ? $match[1] : null).mb_strtoupper($match[2]), 234 | $modelPath 235 | ).'Date'; 236 | 237 | return $this->render($template, [ 238 | 'modelPath' => $modelPath, 239 | 'jsonPath' => $jsonPath, 240 | 'formats' => $formats, 241 | 'formatsError' => $formatsError, 242 | 'format' => '$'.lcfirst($dateVariable).'Format', 243 | 'date' => '$'.lcfirst($dateVariable), 244 | 'timezone' => $timezone ? 'new \DateTimeZone('.var_export($timezone, true).')' : 'null', 245 | ]); 246 | } 247 | 248 | public function renderExtract(string $jsonPath, string $default = 'null'): string 249 | { 250 | return $this->render(self::TMPL_EXTRACT, [ 251 | 'jsonPath' => $jsonPath, 252 | 'default' => $default, 253 | ]); 254 | } 255 | 256 | /** 257 | * @param list $arguments 258 | */ 259 | public function renderCreateObject(string $className, array $arguments): string 260 | { 261 | return $this->render(self::TMPL_CREATE_OBJECT, [ 262 | 'className' => $className, 263 | 'arguments' => $arguments, 264 | ]); 265 | } 266 | 267 | public function renderSetter(string $modelPath, string $method, string $value): string 268 | { 269 | return $this->render(self::TMPL_ASSIGN_SETTER, [ 270 | 'modelPath' => $modelPath, 271 | 'method' => $method, 272 | 'value' => $value, 273 | ]); 274 | } 275 | 276 | public function renderInitArray(string $modelPath): string 277 | { 278 | return $this->render(self::TMPL_INIT_ARRAY, [ 279 | 'modelPath' => $modelPath, 280 | ]); 281 | } 282 | 283 | public function renderLoop(string $jsonPath, string $indexVariable, string $code): string 284 | { 285 | return $this->render(self::TMPL_LOOP, [ 286 | 'jsonPath' => $jsonPath, 287 | 'indexVariable' => $indexVariable, 288 | 'code' => $code, 289 | ]); 290 | } 291 | 292 | public function renderArrayCollection(string $modelPath, string $tmpVariable): string 293 | { 294 | return $this->render(self::TMPL_ARRAY_COLLECTION, [ 295 | 'modelPath' => $modelPath, 296 | 'tmpVariable' => $tmpVariable, 297 | ]); 298 | } 299 | 300 | /** 301 | * @param string[] $variableNames 302 | */ 303 | public function renderUnset(array $variableNames): string 304 | { 305 | return $this->render(self::TMPL_UNSET, [ 306 | 'variableNames' => $variableNames, 307 | ]); 308 | } 309 | 310 | /** 311 | * @param array $parameters 312 | */ 313 | private function render(string $template, array $parameters): string 314 | { 315 | $tmpl = $this->twig->createTemplate($template); 316 | 317 | return $tmpl->render($parameters); 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/Template/Serialization.php: -------------------------------------------------------------------------------- 1 | toArray(); 50 | } else { 51 | $jsonData{{jsonPath}} = {{propertyAccessor}}; 52 | } 53 | EOT; 54 | 55 | private const TMPL_LOOP_ARRAY = <<<'EOT' 56 | {{indexVariable}}Array = {{propertyAccessor}}; 57 | if ({{propertyAccessor}} instanceof \Doctrine\Common\Collections\Collection) { 58 | {{indexVariable}}Array = {{propertyAccessor}}->toArray(); 59 | } 60 | 61 | $jsonData{{jsonPath}} = []; 62 | foreach (array_keys({{indexVariable}}Array) as {{indexVariable}}) { 63 | {{code}} 64 | } 65 | 66 | EOT; 67 | 68 | private const TMPL_LOOP_ARRAY_EMPTY = <<<'EOT' 69 | $jsonData{{jsonPath}} = []; 70 | 71 | EOT; 72 | 73 | private const TMPL_LOOP_HASHMAP = <<<'EOT' 74 | if (0 === \count({{propertyAccessor}})) { 75 | $jsonData{{jsonPath}} = $emptyHashmap; 76 | } else { 77 | {{indexVariable}}Array = {{propertyAccessor}}; 78 | if ({{propertyAccessor}} instanceof \Doctrine\Common\Collections\Collection) { 79 | {{indexVariable}}Array = {{propertyAccessor}}->toArray(); 80 | } 81 | 82 | foreach (array_keys({{indexVariable}}Array) as {{indexVariable}}) { 83 | {{code}} 84 | } 85 | } 86 | 87 | EOT; 88 | 89 | private const TMPL_GETTER = '{{modelPath}}->{{method}}()'; 90 | 91 | private const TMPL_DATETIME = '{{propertyPath}}->format(\'{{format}}\');'; 92 | 93 | private const TMPL_TEMP_VAR = '${{name}} = {{value}}'; 94 | 95 | private Environment $twig; 96 | 97 | public function __construct() 98 | { 99 | $this->twig = new Environment(new ArrayLoader(), ['autoescape' => false]); 100 | } 101 | 102 | public function renderFunction(string $name, string $className, string $code): string 103 | { 104 | return $this->render(self::TMPL_FUNCTION, [ 105 | 'functionName' => $name, 106 | 'className' => $className, 107 | 'code' => $code, 108 | ]); 109 | } 110 | 111 | public function renderClass(string $jsonPath, string $code): string 112 | { 113 | return $this->render(self::TMPL_CLASS, [ 114 | 'jsonPath' => $jsonPath, 115 | 'code' => $code, 116 | ]); 117 | } 118 | 119 | public function renderConditional(string $condition, string $code): string 120 | { 121 | return $this->render(self::TMPL_CONDITIONAL, [ 122 | 'condition' => $condition, 123 | 'code' => $code, 124 | ]); 125 | } 126 | 127 | public function renderAssign(string $jsonPath, string $propertyAccessor): string 128 | { 129 | return $this->render(self::TMPL_ASSIGN, [ 130 | 'jsonPath' => $jsonPath, 131 | 'propertyAccessor' => $propertyAccessor, 132 | ]); 133 | } 134 | 135 | public function renderArrayAssign(string $jsonPath, string $propertyAccessor): string 136 | { 137 | return $this->render(self::TMPL_ARRAY_ASSIGN, [ 138 | 'jsonPath' => $jsonPath, 139 | 'propertyAccessor' => $propertyAccessor, 140 | ]); 141 | } 142 | 143 | public function renderLoopArray(string $jsonPath, string $propertyAccessor, string $indexVariable, string $code): string 144 | { 145 | return $this->render(self::TMPL_LOOP_ARRAY, [ 146 | 'jsonPath' => $jsonPath, 147 | 'propertyAccessor' => $propertyAccessor, 148 | 'indexVariable' => $indexVariable, 149 | 'code' => $code, 150 | ]); 151 | } 152 | 153 | public function renderLoopArrayEmpty(string $jsonPath): string 154 | { 155 | return $this->render(self::TMPL_LOOP_ARRAY_EMPTY, [ 156 | 'jsonPath' => $jsonPath, 157 | ]); 158 | } 159 | 160 | public function renderLoopHashmap(string $jsonPath, string $propertyAccessor, string $indexVariable, string $code): string 161 | { 162 | return $this->render(self::TMPL_LOOP_HASHMAP, [ 163 | 'jsonPath' => $jsonPath, 164 | 'propertyAccessor' => $propertyAccessor, 165 | 'indexVariable' => $indexVariable, 166 | 'code' => $code, 167 | ]); 168 | } 169 | 170 | public function renderLoopHashmapEmpty(string $jsonPath): string 171 | { 172 | return $this->render(self::TMPL_LOOP_HASHMAP, [ 173 | 'jsonPath' => $jsonPath, 174 | ]); 175 | } 176 | 177 | public function renderGetter(string $modelPath, string $method): string 178 | { 179 | return $this->render(self::TMPL_GETTER, [ 180 | 'modelPath' => $modelPath, 181 | 'method' => $method, 182 | ]); 183 | } 184 | 185 | public function renderDateTime(string $propertyPath, string $format): string 186 | { 187 | return $this->render(self::TMPL_DATETIME, [ 188 | 'propertyPath' => $propertyPath, 189 | 'format' => $format, 190 | ]); 191 | } 192 | 193 | public function renderTempVariable(string $name, string $value): string 194 | { 195 | return $this->render(self::TMPL_TEMP_VAR, [ 196 | 'name' => $name, 197 | 'value' => $value, 198 | ]); 199 | } 200 | 201 | public function renderConditionalUsingTempVariable(string $tempVariable, string $propertyAccessor, string $code): string 202 | { 203 | return $this->render(self::TMPL_CONDITIONAL, [ 204 | 'condition' => $this->renderTempVariable($tempVariable, $propertyAccessor), 205 | 'code' => $code, 206 | ]); 207 | } 208 | 209 | /** 210 | * @param array $parameters 211 | */ 212 | private function render(string $template, array $parameters): string 213 | { 214 | $tmpl = $this->twig->createTemplate($template); 215 | 216 | return $tmpl->render($parameters); 217 | } 218 | } 219 | --------------------------------------------------------------------------------