diff --git a/.codeclimate.yml b/.codeclimate.yml deleted file mode 100644 index 06ccbacd..00000000 --- a/.codeclimate.yml +++ /dev/null @@ -1,18 +0,0 @@ -plugins: - phpmd: - enabled: true - config: - file_extensions: "php" - rulesets: "phpmd.xml" - phpcodesniffer: - enabled: true - phan: - enabled: true - config: - file_extensions: "php" - ignore-undeclared: true - sonar-php: - enabled: true - config: - tests_patterns: - - tests/** diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bc408753..2a402d83 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,6 +5,10 @@ on: [push, pull_request] jobs: tests: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + strategy: matrix: php: ['8.0', '8.1', '8.3', '8.4', '8.5'] @@ -21,33 +25,37 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: mbstring, json + extensions: mbstring coverage: ${{ matrix.coverage && 'xdebug' || 'none' }} - name: Install dependencies run: composer update --no-interaction + # Run tests WITHOUT coverage on non-coverage matrix jobs - name: Execute tests - if: ${{ ! matrix.coverage }} - run: | - ./vendor/bin/phpunit --testdox + if: ${{ !matrix.coverage }} + run: ./vendor/bin/phpunit --testdox - - name: Upload the reports to codeclimate + # Run tests WITH Clover coverage for the coverage job + - name: Execute tests with coverage (Clover) if: ${{ matrix.coverage }} env: XDEBUG_MODE: coverage - CC_TEST_REPORTER_ID: 5e32818628fac9eb11d34e2b35289f88169610cc4a98c6f170c74923342284f1 - uses: paambaati/codeclimate-action@v9 + run: ./vendor/bin/phpunit --coverage-clover=build/logs/clover.xml --testdox + + - name: Upload coverage to Qlty + if: ${{ matrix.coverage }} + uses: qltysh/qlty-action/coverage@v2 with: - coverageCommand: | - ./vendor/bin/phpunit --coverage-clover=build/logs/clover.xml --testdox + oidc: true + files: build/logs/clover.xml - name: Upload the reports to coveralls.io if: ${{ matrix.coverage }} - env: - COVERALLS_REPO_TOKEN: ${{ github.token }} uses: coverallsapp/github-action@v2 with: - github-token: ${{ env.COVERALLS_REPO_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + file: build/logs/clover.xml + format: clover flag-name: Unit - allow-empty: false + allow-empty: false \ No newline at end of file diff --git a/README.md b/README.md index ec7bc395..0f8067a9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Latest Version](https://img.shields.io/packagist/v/wol-soft/php-json-schema-model-generator.svg)](https://packagist.org/packages/wol-soft/php-json-schema-model-generator) [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.0-8892BF.svg)](https://php.net/) -[![Maintainability](https://api.codeclimate.com/v1/badges/7eb29e7366dc3d6a5f44/maintainability)](https://codeclimate.com/github/wol-soft/php-json-schema-model-generator/maintainability) +[![Maintainability](https://qlty.sh/gh/wol-soft/projects/php-json-schema-model-generator/maintainability.svg)](https://qlty.sh/gh/wol-soft/projects/php-json-schema-model-generator) [![Build Status](https://github.com/wol-soft/php-json-schema-model-generator/actions/workflows/main.yml/badge.svg)](https://github.com/wol-soft/php-json-schema-model-generator/actions/workflows/main.yml) [![Coverage Status](https://coveralls.io/repos/github/wol-soft/php-json-schema-model-generator/badge.svg?branch=master)](https://coveralls.io/github/wol-soft/php-json-schema-model-generator?branch=master) [![MIT License](https://img.shields.io/packagist/l/wol-soft/php-json-schema-model-generator.svg)](https://github.com/wol-soft/php-json-schema-model-generator/blob/master/LICENSE) diff --git a/composer.json b/composer.json index 572ab89d..2ef3f27e 100644 --- a/composer.json +++ b/composer.json @@ -12,11 +12,10 @@ ], "require": { "symfony/polyfill-php81": "^1.28", - "wol-soft/php-json-schema-model-generator-production": "^0.19.0", - "wol-soft/php-micro-template": "^1.9.0", + "wol-soft/php-json-schema-model-generator-production": "dev-BuilderClassPostProcessor", + "wol-soft/php-micro-template": "^1.10.0", "php": ">=8.0", - "ext-json": "*", "ext-mbstring": "*" }, "require-dev": { diff --git a/docs/source/combinedSchemas/allOf.rst b/docs/source/combinedSchemas/allOf.rst index 108e346d..3a68e561 100644 --- a/docs/source/combinedSchemas/allOf.rst +++ b/docs/source/combinedSchemas/allOf.rst @@ -30,7 +30,7 @@ Generated interface: .. code-block:: php - public function setExample(float $example): self; + public function setExample(float $example): static; public function getExample(): float; diff --git a/docs/source/combinedSchemas/anyOf.rst b/docs/source/combinedSchemas/anyOf.rst index 3b9c54cf..41955bc8 100644 --- a/docs/source/combinedSchemas/anyOf.rst +++ b/docs/source/combinedSchemas/anyOf.rst @@ -30,7 +30,7 @@ Generated interface: .. code-block:: php - public function setExample(float $example): self; + public function setExample(float $example): static; public function getExample(): float; diff --git a/docs/source/combinedSchemas/if.rst b/docs/source/combinedSchemas/if.rst index 8237bb09..63bb0889 100644 --- a/docs/source/combinedSchemas/if.rst +++ b/docs/source/combinedSchemas/if.rst @@ -30,7 +30,7 @@ Generated interface: .. code-block:: php - public function setExample(float $example): self; + public function setExample(float $example): static; public function getExample(): ?float; Possible exception (in this case 50 was provided so the if condition succeeds but the then branch failed): @@ -112,8 +112,8 @@ Generated interface: .. code-block:: php - public function setCountry(string $country): self; + public function setCountry(string $country): static; public function getCountry(): ?string; - public function setPostalCode(string $country): self; + public function setPostalCode(string $country): static; public function getPostalCode(): ?string; diff --git a/docs/source/combinedSchemas/mergedProperty.rst b/docs/source/combinedSchemas/mergedProperty.rst index 201adbb2..3e2ce272 100644 --- a/docs/source/combinedSchemas/mergedProperty.rst +++ b/docs/source/combinedSchemas/mergedProperty.rst @@ -53,14 +53,14 @@ Generated interface: .. code-block:: php # class Company - public function setCeo(?Company_Merged_CEO $example): self; + public function setCeo(Company_Merged_CEO $example): static; public function getCeo(): ?Company_Merged_CEO; # class Company_Merged_CEO public function getName(): ?string - public function setName(?string $name): self + public function setName(string $name): static public function getAge(): ?int - public function setAge(?int $name): self + public function setAge(int $name): static If your composition is defined on object level the object will gain access to all properties of the combined schemas: @@ -99,6 +99,6 @@ This schema will generate three classes as no merged property is created. The ma # class CEO public function getName(): ?string - public function setName(?string $name): self + public function setName(string $name): static public function getAge(): ?int - public function setAge(?int $name): self + public function setAge(int $name): static diff --git a/docs/source/combinedSchemas/not.rst b/docs/source/combinedSchemas/not.rst index b69bc3cc..0b1d4a07 100644 --- a/docs/source/combinedSchemas/not.rst +++ b/docs/source/combinedSchemas/not.rst @@ -21,7 +21,7 @@ Generated interface: .. code-block:: php - public function setExample($example): self; + public function setExample($example): static; public function getExample(); diff --git a/docs/source/combinedSchemas/oneOf.rst b/docs/source/combinedSchemas/oneOf.rst index 525ec7c2..cc8035c1 100644 --- a/docs/source/combinedSchemas/oneOf.rst +++ b/docs/source/combinedSchemas/oneOf.rst @@ -30,7 +30,7 @@ Generated interface: .. code-block:: php - public function setExample(float $example): self; + public function setExample(float $example): static; public function getExample(): float; diff --git a/docs/source/complexTypes/array.rst b/docs/source/complexTypes/array.rst index b9b8aece..43857607 100644 --- a/docs/source/complexTypes/array.rst +++ b/docs/source/complexTypes/array.rst @@ -24,7 +24,7 @@ Generated interface: .. code-block:: php - public function setExample(array $example): self; + public function setExample(array $example): static; // As the property is not required it may be initialized with null. Consequently the return value is nullable public function getExample(): ?array; @@ -118,14 +118,14 @@ In this case the model generator will generate two classes: **Family** and **Mem .. code-block:: php // class Family - public function setMembers(array $members): self; + public function setMembers(array $members): static; public function getMembers(): ?array; // class Member - public function setName(string $name): self; + public function setName(string $name): static; public function getName(): string; - public function setAge(int $age): self; + public function setAge(int $age): static; public function getAge(): ?int; The *getMembers* function of the class *Family* is type hinted with *@returns Member[]*. Consequently auto completion is available when developing something like: diff --git a/docs/source/complexTypes/enum.rst b/docs/source/complexTypes/enum.rst index c5128b35..4c567111 100644 --- a/docs/source/complexTypes/enum.rst +++ b/docs/source/complexTypes/enum.rst @@ -5,7 +5,7 @@ Enums can be used to define a set of constant values a property must accept. .. hint:: - If you define constraints via `enum` you may want to use the `EnumPostProcessor <../generator/postProcessor.html#enumpostprocessor>`__ to generate PHP enums. + If you define constraints via `enum` you may want to use the `EnumPostProcessor <../generator/builtin/enumPostProcessor.html>`__ to generate PHP enums. .. code-block:: json @@ -24,7 +24,7 @@ Generated interface: .. code-block:: php - public function setExample(?string $example): self; + public function setExample(string $example): static; public function getExample(): ?string; Possible exceptions: @@ -53,7 +53,7 @@ Generated interface (no typehints are generated as it's a mixed untyped enum. If .. code-block:: php - public function setExample($example): self; + public function setExample($example): static; public function getExample(); Possible exceptions: diff --git a/docs/source/complexTypes/multiType.rst b/docs/source/complexTypes/multiType.rst index 91821de8..da6d7634 100644 --- a/docs/source/complexTypes/multiType.rst +++ b/docs/source/complexTypes/multiType.rst @@ -20,7 +20,7 @@ Generated interface (doesn't contain type hints as multiple types are allowed): .. code-block:: php // $example will be type-annotated with `float|string` - public function setExample($example): self; + public function setExample($example): static; // $example will be type-annotated with `float|string|null` (as the property isn't required) public function getExample(); @@ -62,7 +62,7 @@ For each type given in the allowed types array additional validators may be adde } } -The property example will be type hinted with `float|string|string[]|null`. +The property example will be type hinted with `float|string|string[]`. The validators are applied if the given input matches the corresponding type. For example if an array **["Hello", 123, "Goodbye"]** is given the validation will fail as numbers aren't allowed in arrays: diff --git a/docs/source/complexTypes/object.rst b/docs/source/complexTypes/object.rst index db2c87c3..ac965cba 100644 --- a/docs/source/complexTypes/object.rst +++ b/docs/source/complexTypes/object.rst @@ -32,16 +32,16 @@ Generated interface: .. code-block:: php // class Person - public function setName(string $name): self; + public function setName(string $name): static; // As the property is not required it may be initialized with null. Consequently the return value is nullable public function getName(): ?string; - public function setCar(Car $name): self; + public function setCar(Car $name): static; public function getCar(): ?Car; // class Car - public function setModel(string $name): self; + public function setModel(string $name): static; public function getModel(): ?string; - public function setPs(int $name): self; + public function setPs(int $name): static; public function getPs(): ?int; Possible exceptions: @@ -123,9 +123,9 @@ Generated interface: .. code-block:: php - public function setUnderscorePropertyMinus(string $name): self; + public function setUnderscorePropertyMinus(string $name): static; public function getUnderscorePropertyMinus(): ?string; - public function setCapsAndSpace100(string $name): self; + public function setCapsAndSpace100(string $name): static; public function getCapsAndSpace100(): ?string; If the name normalization results in an empty attribute name (eg. '__ -- __') an exception will be thrown. @@ -162,7 +162,7 @@ Possible exceptions: Properties defined in the `required` array but not defined in the `properties` will be added to the interface of the generated class. - A schema defining only the required property `example` consequently will provide the methods `getExample(): mixed` and `setExample(mixed $value): self`. + A schema defining only the required property `example` consequently will provide the methods `getExample(): mixed` and `setExample(mixed $value): static`. Size ---- @@ -208,7 +208,7 @@ Using the keyword `additionalProperties` the object can be limited to not contai .. hint:: - If you define constraints via `additionalProperties` you may want to use the `AdditionalPropertiesAccessorPostProcessor <../generator/postProcessor.html#additionalpropertiesaccessorpostprocessor>`__ to access and modify your additional properties. + If you define constraints via `additionalProperties` you may want to use the `AdditionalPropertiesAccessorPostProcessor <../generator/builtin/additionalPropertiesAccessorPostProcessor.html>`__ to access and modify your additional properties. .. code-block:: json @@ -315,13 +315,13 @@ Generated interface: .. code-block:: php // class Family, arrays type hinted in docblocks with Family_Person[] - public function setMembers(array $members): self; + public function setMembers(array $members): static; public function getMembers(): ?array; // class Person, arrays type hinted in docblocks with Family_Person[] - public function setName(string $name): self; + public function setName(string $name): static; public function getName(): ?string; - public function setChildren(array $name): self; + public function setChildren(array $name): static; public function getChildren(): ?array; Property Names @@ -479,14 +479,14 @@ Generated interface: // class CreditCardOwner // base properties - public function setCreditCard(int $creditCard): self; + public function setCreditCard(int $creditCard): static; public function getCreditCard(): ?int; // inherited properties // the inherited properties will not be type hinted as they may contain any value if credit_card isn't present. - public function setBillingAddress($billingAddress): self; + public function setBillingAddress($billingAddress): static; public function getBillingAddress(); - public function setDateOfBirth($dateOfBirth): self; + public function setDateOfBirth($dateOfBirth): static; public function getDateOfBirth(); .. hint:: @@ -520,7 +520,7 @@ Using the keyword `patternProperties` further restrictions for properties matchi .. hint:: - If you define constraints via `patternProperties` you may want to use the `PatternPropertiesAccessorPostProcessor <../generator/postProcessor.html#patternpropertiesaccessorpostprocessor>`__ to access your pattern properties. + If you define constraints via `patternProperties` you may want to use the `PatternPropertiesAccessorPostProcessor <../generator/builtin/patternPropertiesAccessorPostProcessor.html>`__ to access your pattern properties. .. code-block:: json diff --git a/docs/source/conf.py b/docs/source/conf.py index 91e14f5b..098ac05f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -20,13 +20,13 @@ # -- Project information ----------------------------------------------------- project = u'php-json-schema-model-generator' -copyright = u'2023, Enno Woortmann' +copyright = u'2025, Enno Woortmann' author = u'Enno Woortmann' # The short X.Y version -version = u'0.24' +version = u'0.26' # The full version, including alpha/beta/rc tags -release = u'0.24.0' +release = u'0.26.0' # -- General configuration --------------------------------------------------- diff --git a/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst b/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst new file mode 100644 index 00000000..60a54b04 --- /dev/null +++ b/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst @@ -0,0 +1,62 @@ +AdditionalPropertiesAccessorPostProcessor +========================================= + +.. code-block:: php + + $generator = new ModelGenerator(); + $generator->addPostProcessor(new AdditionalPropertiesAccessorPostProcessor(true)); + +The **AdditionalPropertiesAccessorPostProcessor** adds methods to your model to work with `additional properties <../../complexTypes/object.html#additional-properties>`__ on your objects. By default the post processor only adds methods to objects from a schema which defines constraints for additional properties. If the first constructor parameter *$addForModelsWithoutAdditionalPropertiesDefinition* is set to true the methods will also be added to objects generated from a schema which doesn't define additional properties constraints. If the *additionalProperties* keyword in a schema is set to false the methods will never be added. + +.. note:: + + If the `deny additional properties setting <../../gettingStarted.html#deny-additional-properties>`__ is set to true the setting *$addForModelsWithoutAdditionalPropertiesDefinition* is ignored as all objects which don't define additional properties are restricted to the defined properties + +Added methods +~~~~~~~~~~~~~ + +.. code-block:: json + + { + "$id": "example", + "type": "object", + "properties": { + "example": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + } + } + +Generated interface with the **AdditionalPropertiesAccessorPostProcessor**: + +.. code-block:: php + + public function getRawModelDataInput(): array; + + public function setExample(float $example): static; + public function getExample(): float; + + public function getAdditionalProperties(): array; + public function getAdditionalProperty(string $property): ?string; + public function setAdditionalProperty(string $property, string $value): static; + public function removeAdditionalProperty(string $property): bool; + +.. note:: + + The methods **setAdditionalProperty** and **removeAdditionalProperty** are only added if the `immutable setting <../../gettingStarted.html#immutable-classes>`__ is set to false. + +**getAdditionalProperties**: This method returns all additional properties which are currently part of the model as key-value pairs where the key is the property name and the value the current value stored in the model. All other properties which are part of the object (in this case the property *example*) will not be included. In opposite to the *getRawModelDataInput* the values provided via this method are the processed values. This means if the schema provides an object-schema for additional properties an array of object instances will be returned. If the additional properties schema contains `filter <../../nonStandardExtensions/filter.html>`__ the filtered (and in case of transforming filter transformed) values will be returned. + +**getAdditionalProperty**: Returns the current value of a single additional property. If the requested property doesn't exist null will be returned. Returns as well as *getAdditionalProperties* the processed values. + +**setAdditionalProperty**: Adds or updates an additional property. Performs all necessary validations like property names or min and max properties validations. If the additional properties are processed via a transforming filter an already transformed value will be accepted. If a property which is regularly defined in the schema a *RegularPropertyAsAdditionalPropertyException* will be thrown. If the change is valid and performed also the output of *getRawModelDataInput* will be updated. + +**removeAdditionalProperty**: Removes an existing additional property from the model. Returns true if the additional property has been removed, false otherwise (if no additional property with the requested key exists). May throw a *MinPropertiesException* if the change would result in an invalid model state. If the change is valid and performed also the output of *getRawModelDataInput* will be updated. + +Serialization +~~~~~~~~~~~~~ + +By default additional properties are only included in the serialized models if the *additionalProperties* field is set to true or contains further restrictions. If the option *$addForModelsWithoutAdditionalPropertiesDefinition* is set to true also additional properties for entities which don't define the *additionalProperties* field will be included in the serialization result. If the **AdditionalPropertiesAccessorPostProcessor** is applied and `serialization <../../gettingStarted.html#serialization-methods>`__ is enabled the additional properties will be merged into the serialization result. If the additional properties are processed via a transforming filter each value will be serialized via the serialisation method of the transforming filter. diff --git a/docs/source/generator/builtin/builderClassPostProcessor.rst b/docs/source/generator/builtin/builderClassPostProcessor.rst new file mode 100644 index 00000000..8e82fad4 --- /dev/null +++ b/docs/source/generator/builtin/builderClassPostProcessor.rst @@ -0,0 +1,157 @@ +BuilderClassPostProcessor +========================= + +.. code-block:: php + + $generator = new ModelGenerator(); + $generator->addPostProcessor(new BuilderClassPostProcessor()); + +The **BuilderClassPostProcessor** generates a builder class for each model class generated from your schemas. +The generated builder classes can be used to populate the object gradually (e.g. building a response object). +Additionally, a validate method is added to the builder classes which converts the builder class into the corresponding model class and performs a full validation. +The builder class always shares the namespace with it's corresponding model class. + +.. hint:: + + If your model contains `readonly <../../generic/readonly.html>`__ properties or `immutability <../../gettingStarted.html#immutable-classes>`__ is enabled, the builder class of course will generate setter methods independent of those settings. + +Let's have a look at a simple object and the generated classes: + +.. code-block:: json + + { + "$id": "example", + "type": "object", + "properties": { + "example": { + "type": "string" + } + }, + "required": [ + "example" + ] + } + +In this case the model generator will generate two classes: **Example** and **ExampleBuilder**. Generated interfaces: + +.. code-block:: php + + // class ExampleBuilder + public function setExample(string $members): static; + public function getExample(): ?string; + + public function validate(): Example; + + // class Example + public function setExample(string $members): static; + public function getExample(): string; + +Note, that the *getExample* method of the **ExampleBuilder** can return null. +This applies for all getter methods of the builder instance, as we don't know if the property has already been set on the object. +In contrast to the general model class, which is populated via an array provided in the constructor, the constructor of the builder class doesn't accept any data, the instances have to be populated via the setter methods. +The setter methods don't perform further validation, so if your property has for example a **minLength** constraint and the value you set doesn't fulfill the constraint, the set will not fail. +The call to validate will populate a new instance of **Example** and perform a full validation of all constraints and, in case of violations, throw an exception matching your `error handling configuration <../../gettingStarted.html#collect-errors-vs-early-return>`__. + +If we want to implement the builder as a builder for responses, a full code example might look like the following (assuming `serialization <../../gettingStarted.html#serialization-methods>`__ is enabled): + +.. code-block:: php + + $builder = new ExampleBuilder(); + $builder->setExample('123abc'); + + // this call will throw an exception on violations against the JSON schema + $responseBody = $builder->validate(); + $response = new Response($responseBody->toJSON()); + +Nested objects +~~~~~~~~~~~~~~ + +When your schema provides nested objects, you have different options to populate the nested object in the builder class. +As the **BuilderClassPostProcessor** generates a builder class for each generated model class, you can of course simply use the builder class to populate the nested object. +In this case, you don't need to perform the validation yourself but leave it for the main call to the parents *validate* method. +Nevertheless, you can validate the object and pass a fully validated instance of the nested object (or, if you haven't used the builder but instantiated the object in a different way, that's also perfectly fine). +As a third option, you can simply pass an array with the values for the nested object. + +.. code-block:: json + + { + "$id": "location", + "type": "object", + "properties": { + "coordinates": { + "$id": "coordinates", + "type": "object", + "properties": { + "latitude": { + "type": "string" + }, + "longitude": { + "type": "string" + }, + }, + "required": [ + "latitude", + "longitude" + ] + } + } + } + +In this case the model generator will generate four classes with the following interfaces: + +.. code-block:: php + + // class CoordinatesBuilder + public function setLatitude(string $latitude): static; + public function setLongitude(string $longitude): static; + public function getLatitude(): ?string; + public function getLongitude(): ?string; + + public function validate(): Coordinates; + + // class Coordinates + public function setLatitude(string $latitude): static; + public function setLongitude(string $longitude): static; + public function getLatitude(): string; + public function getLongitude(): string; + + // class LocationBuilder + + // $coordinates accepts an instance of Coordinates, CoordinatesBuilder or an array. + // If an array is passed, the keys 'latitude' and 'longitude' must be present for a successful validation + public function setCoordinates($coordinates): static; + // returns, whatever you passed to setCoordinates, or null, if you haven't called setCoordinates yet + public function getCoordinates(); + + public function validate(): Location; + + // class Location + public function setCoordinates(Coordinates $coordinates): static; + public function getCoordinates(): ?Coordinates; + +Let's have a look at the usage of the generated classes with the different approaches of populating the **Coordinates** on the **LocationBuilder**: + +.. code-block:: php + + $latitude = '53°7\'6"N'; + $longitude = '7°27\'43"E'; + $locationBuilder = new LocationBuilder(); + + // option 1: passing an array with the data + $locationBuilder->setCoordinates(['latitude' => $latitude, 'longitude' => $longitude]); + + // option 2: passing an instance of the CoordinatesBuilder + $coordinatesBuilder = new CoordinatesBuilder(); + $coordinatesBuilder->setLatitude($latitude); + $coordinatesBuilder->setLongitude($longitude); + $locationBuilder->setCoordinates($coordinatesBuilder); + + // option 3: passing an instance of Coordinates, + // either by manually validating the builder or by instantiating it directly. + // Both options might throw exceptions if the data is not valid for the Coordinates class + $locationBuilder->setCoordinates($coordinatesBuilder->validate()); + $locationBuilder->setCoordinates(new Coordinates(['latitude' => $latitude, 'longitude' => $longitude])); + +The same behaviour applies, if the property of the parent object holds an array of nested objects. +In this case, each element of the nested array might use any of the possible options. +The call to the *validate* method on the parent object will cause all elements to be instantiated with the corresponding model class. diff --git a/docs/source/generator/builtin/enumPostProcessor.rst b/docs/source/generator/builtin/enumPostProcessor.rst new file mode 100644 index 00000000..dc566d14 --- /dev/null +++ b/docs/source/generator/builtin/enumPostProcessor.rst @@ -0,0 +1,94 @@ +EnumPostProcessor +================= + +.. warning:: + + Requires at least PHP 8.1 + +.. code-block:: php + + $generator = new ModelGenerator(); + $generator->addPostProcessor(new EnumPostProcessor(__DIR__ . '/generated/enum/', '\\MyApp\\Enum')); + +The **EnumPostProcessor** generates a `PHP enum `_ for each `enum <../../complexTypes/enum.html>`__ found in the processed schemas. +Enums which contain only integer values or only string values will be rendered into a `backed enum `_. +Other enums will provide the following interface similar to the capabilities of a backed enum: + +.. code-block:: php + + public static function from(mixed $value): self; + public static function tryFrom(mixed $value): ?self; + + public function value(): mixed; + +Let's have a look at the most simple case of a string-only enum: + +.. code-block:: json + + { + "$id": "offer", + "type": "object", + "properties": { + "state": { + "enum": ["open", "sold", "cancelled"] + } + } + } + +The provided schema will generate the following enum: + +.. code-block:: php + + enum OfferState: string { + case Open = 'open'; + case Sold = 'sold'; + case Cancelled = 'cancelled'; + } + +The type hints and annotations of the generated class will be changed to match the generated enum: + +.. code-block:: php + + /** + * @param OfferState|string $state + */ + public function setState($state): static; + public function getState(): ?OfferState; + +Mapping +~~~~~~~ + +Each enum which is not a string-only enum must provide a mapping in the **enum-map** property, for example an integer-only enum: + +.. code-block:: json + + { + "$id": "offer", + "type": "object", + "properties": { + "state": { + "enum": [0, 1, 2], + "enum-map": { + "open": 0, + "sold": 1, + "cancelled": 2 + } + } + } + } + +The provided schema will generate the following enum: + +.. code-block:: php + + enum OfferState: int { + case Open = 0; + case Sold = 1; + case Cancelled = 2; + } + +If an enum which requires a mapping is found but no mapping is provided a **SchemaException** will be thrown. + +.. note:: + + By enabling the *$skipNonMappedEnums* option of the **EnumPostProcessor** you can skip enums which require a mapping but don't provide a mapping. Those enums will provide the default `enum <../../complexTypes/enum.html>`__ behaviour. diff --git a/docs/source/generator/builtin/patternPropertiesAccessorPostProcessor.rst b/docs/source/generator/builtin/patternPropertiesAccessorPostProcessor.rst new file mode 100644 index 00000000..434c1e1c --- /dev/null +++ b/docs/source/generator/builtin/patternPropertiesAccessorPostProcessor.rst @@ -0,0 +1,60 @@ +PatternPropertiesAccessorPostProcessor +====================================== + +.. code-block:: php + + $generator = new ModelGenerator(); + $generator->addPostProcessor(new PatternPropertiesAccessorPostProcessor()); + +The **PatternPropertiesAccessorPostProcessor** adds methods to your model to work with `pattern properties <../../complexTypes/object.html#pattern-properties>`__ on your objects. The methods will only be added if the schema for the object defines pattern properties. + +Added methods +~~~~~~~~~~~~~ + +.. code-block:: json + + { + "$id": "example", + "type": "object", + "properties": { + "example": { + "type": "string" + } + }, + "patternProperties": { + "^a": { + "type": "string" + }, + "^b": { + "key": "numbers" + "type": "integer" + }, + } + } + +Generated interface with the **PatternPropertiesAccessorPostProcessor**: + +.. code-block:: php + + public function getRawModelDataInput(): array; + + public function setExample(float $example): static; + public function getExample(): float; + + public function getPatternProperties(string $key): array; + +The added method **getPatternProperties** can be used to fetch a list of all properties matching the given pattern. As *$key* you have to provide the pattern you want to fetch. Alternatively you can define a key in your schema and use the key to fetch the properties. + +.. code-block:: php + + $myObject = new Example('a1' => 'Hello', 'b1' => 100); + + // fetches all properties matching the pattern '^a', consequently will return ['a1' => 'Hello'] + $myObject->getPatternProperties('^a'); + + // fetches all properties matching the pattern '^b' (which has a defined key), consequently will return ['b1' => 100] + $myObject->getPatternProperties('numbers'); + +.. note:: + + If you want to modify your object by adding or removing pattern properties after the object instantiation you can use the `AdditionalPropertiesAccessorPostProcessor `__ or the `PopulatePostProcessor `__ diff --git a/docs/source/generator/builtin/populatePostProcessor.rst b/docs/source/generator/builtin/populatePostProcessor.rst new file mode 100644 index 00000000..8eb28bb1 --- /dev/null +++ b/docs/source/generator/builtin/populatePostProcessor.rst @@ -0,0 +1,65 @@ +PopulatePostProcessor +===================== + +.. code-block:: php + + $generator = new ModelGenerator(); + $generator->addPostProcessor(new PopulatePostProcessor()); + +The **PopulatePostProcessor** adds a populate method to your generated model. The populate method accepts an array which might contain any subset of the model's properties. All properties present in the provided array will be validated according to the validation rules from the JSON-Schema. If all values are valid the properties will be updated otherwise an exception will be thrown (if error collection is enabled an exception containing all violations, otherwise on the first occurring error, compare `collecting errors <../../gettingStarted.html#collect-errors-vs-early-return>`__). Also basic model constraints like `minProperties`, `maxProperties` or `propertyNames` will be validated as the provided array may add additional properties to the model. If the model is updated also the values which can be fetched via `getRawModelDataInput` will be updated. + +.. code-block:: json + + { + "$id": "example", + "type": "object", + "properties": { + "example": { + "type": "string" + } + } + } + +Generated interface with the **PopulatePostProcessor**: + +.. code-block:: php + + public function getRawModelDataInput(): array; + + public function setExample(float $example): static; + public function getExample(): float; + + public function populate(array $modelData): static; + +Now let's have a look at the behaviour of the generated model: + +.. code-block:: php + + // initialize the model with a valid value + $example = new Example(['value' => 'Hello World']); + $example->getRawModelDataInput(); // returns ['value' => 'Hello World'] + + // add an additional property to the model. + // if additional property constraints are defined in your JSON-Schema + // each additional property will be validated against the defined constraints. + $example->populate(['additionalValue' => 12]); + $example->getRawModelDataInput(); // returns ['value' => 'Hello World', 'additionalValue' => 12] + + // update an existing property with a valid value + $example->populate(['value' => 'Good night!']); + $example->getRawModelDataInput(); // returns ['value' => 'Good night!', 'additionalValue' => 12] + + // update an existing property with an invalid value which will throw an exception + try { + $example->populate(['value' => false]); + } catch (Exception $e) { + // perform error handling + } + // if the update of the model fails no values will be updated + $example->getRawModelDataInput(); // returns ['value' => 'Good night!', 'additionalValue' => 12] + +.. warning:: + + If the **PopulatePostProcessor** is added to your model generator the populate method will be added to the model independently of the `immutable setting <../../gettingStarted.html#immutable-classes>`__. + +The **PopulatePostProcessor** will also resolve all hooks which are applied to setters. Added code will be executed for all properties changed by a populate call. Schema hooks which implement the **SetterAfterValidationHookInterface** will only be executed if all provided properties pass the validation. diff --git a/docs/source/generator/custom/customPostProcessor.rst b/docs/source/generator/custom/customPostProcessor.rst new file mode 100644 index 00000000..c7adcbb9 --- /dev/null +++ b/docs/source/generator/custom/customPostProcessor.rst @@ -0,0 +1,46 @@ +Custom Post Processors +====================== + +You can implement custom post processors to accomplish your tasks. Each post processor must extend the class **PHPModelGenerator\\SchemaProcessor\\PostProcessor\\PostProcessor**. If you have implemented a post processor add the post processor to your `ModelGenerator` and the post processor will be executed for each class. + +A custom post processor which adds a custom trait to the generated model (eg. a trait adding methods for an active record pattern implementation) may look like: + +.. code-block:: php + + namespace MyApp\Model\Generator\PostProcessor; + + use MyApp\Model\ActiveRecordTrait; + use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; + + class ActiveRecordPostProcessor extends PostProcessor + { + public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void + { + $schema->addTrait(ActiveRecordTrait::class); + } + } + +.. hint:: + + For examples how to implement a custom post processor have a look at the built in post processors located at **src/SchemaProcessor/PostProcessor/** + +What can you do inside your custom post processor? + +* Add additional traits and interfaces to your models +* Add additional methods and properties to your models +* Hook via **SchemaHooks** into the generated source code and add your snippets at defined places inside the model: + + * Implement the **ConstructorBeforeValidationHookInterface** to add code to the beginning of your constructor + * Implement the **ConstructorAfterValidationHookInterface** to add code to the end of your constructor + * Implement the **GetterHookInterface** to add code to your getter methods + * Implement the **SetterBeforeValidationHookInterface** to add code to the beginning of your setter methods + * Implement the **SetterAfterValidationHookInterface** to add code to the end of your setter methods + * Implement the **SerializationHookInterface** to add code to the end of your serialization process + +.. warning:: + + If a setter for a property is called with the same value which is already stored internally (consequently no update of the property is required), the setters will return directly and as a result of that the setter hooks will not be executed. + + This behaviour also applies also to properties changed via the *populate* method added by the `PopulatePostProcessor <#populatepostprocessor>`__ and the *setAdditionalProperty* method added by the `AdditionalPropertiesAccessorPostProcessor <#additionalpropertiesaccessorpostprocessor>`__ + +To execute code before/after the processing of the schemas override the methods **preProcess** and **postProcess** inside your custom post processor. diff --git a/docs/source/generator/postProcessor.rst b/docs/source/generator/postProcessor.rst index 730aa2e2..2ed64b35 100644 --- a/docs/source/generator/postProcessor.rst +++ b/docs/source/generator/postProcessor.rst @@ -13,337 +13,18 @@ Post processors provide an easy way to extend your generated code. A post proces All added post processors will be executed after a schema was processed and before a model is rendered. Consequently a post processor can be used to change the generated class or to extend the class. Also additional tasks which don't change the rendered code may be executed (eg. create a documentation file for the class, create SQL create statements for tables representing the class, ...). -Builtin Post Processors ------------------------ +.. toctree:: + :caption: Builtin Post Processors + :maxdepth: 1 -PopulatePostProcessor -^^^^^^^^^^^^^^^^^^^^^ + builtin/builderClassPostProcessor + builtin/enumPostProcessor + builtin/populatePostProcessor + builtin/additionalPropertiesAccessorPostProcessor + builtin/patternPropertiesAccessorPostProcessor -.. code-block:: php - - $generator = new ModelGenerator(); - $generator->addPostProcessor(new PopulatePostProcessor()); - -The **PopulatePostProcessor** adds a populate method to your generated model. The populate method accepts an array which might contain any subset of the model's properties. All properties present in the provided array will be validated according to the validation rules from the JSON-Schema. If all values are valid the properties will be updated otherwise an exception will be thrown (if error collection is enabled an exception containing all violations, otherwise on the first occurring error, compare `collecting errors <../gettingStarted.html#collect-errors-vs-early-return>`__). Also basic model constraints like `minProperties`, `maxProperties` or `propertyNames` will be validated as the provided array may add additional properties to the model. If the model is updated also the values which can be fetched via `getRawModelDataInput` will be updated. - -.. code-block:: json - - { - "$id": "example", - "type": "object", - "properties": { - "example": { - "type": "string" - } - } - } - -Generated interface with the **PopulatePostProcessor**: - -.. code-block:: php - - public function getRawModelDataInput(): array; - - public function setExample(float $example): self; - public function getExample(): float; - - public function populate(array $modelData): self; - -Now let's have a look at the behaviour of the generated model: - -.. code-block:: php - - // initialize the model with a valid value - $example = new Example(['value' => 'Hello World']); - $example->getRawModelDataInput(); // returns ['value' => 'Hello World'] - - // add an additional property to the model. - // if additional property constraints are defined in your JSON-Schema - // each additional property will be validated against the defined constraints. - $example->populate(['additionalValue' => 12]); - $example->getRawModelDataInput(); // returns ['value' => 'Hello World', 'additionalValue' => 12] - - // update an existing property with a valid value - $example->populate(['value' => 'Good night!']); - $example->getRawModelDataInput(); // returns ['value' => 'Good night!', 'additionalValue' => 12] - - // update an existing property with an invalid value which will throw an exception - try { - $example->populate(['value' => false]); - } catch (Exception $e) { - // perform error handling - } - // if the update of the model fails no values will be updated - $example->getRawModelDataInput(); // returns ['value' => 'Good night!', 'additionalValue' => 12] - -.. warning:: - - If the **PopulatePostProcessor** is added to your model generator the populate method will be added to the model independently of the `immutable setting <../gettingStarted.html#immutable-classes>`__. - -The **PopulatePostProcessor** will also resolve all hooks which are applied to setters. Added code will be executed for all properties changed by a populate call. Schema hooks which implement the **SetterAfterValidationHookInterface** will only be executed if all provided properties pass the validation. - -AdditionalPropertiesAccessorPostProcessor -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: php - - $generator = new ModelGenerator(); - $generator->addPostProcessor(new AdditionalPropertiesAccessorPostProcessor(true)); - -The **AdditionalPropertiesAccessorPostProcessor** adds methods to your model to work with `additional properties <../complexTypes/object.html#additional-properties>`__ on your objects. By default the post processor only adds methods to objects from a schema which defines constraints for additional properties. If the first constructor parameter *$addForModelsWithoutAdditionalPropertiesDefinition* is set to true the methods will also be added to objects generated from a schema which doesn't define additional properties constraints. If the *additionalProperties* keyword in a schema is set to false the methods will never be added. - -.. note:: - - If the `deny additional properties setting <../gettingStarted.html#deny-additional-properties>`__ is set to true the setting *$addForModelsWithoutAdditionalPropertiesDefinition* is ignored as all objects which don't define additional properties are restricted to the defined properties - -Added methods -~~~~~~~~~~~~~ - -.. code-block:: json - - { - "$id": "example", - "type": "object", - "properties": { - "example": { - "type": "string" - } - }, - "additionalProperties": { - "type": "string" - } - } - -Generated interface with the **AdditionalPropertiesAccessorPostProcessor**: - -.. code-block:: php - - public function getRawModelDataInput(): array; - - public function setExample(float $example): self; - public function getExample(): float; - - public function getAdditionalProperties(): array; - public function getAdditionalProperty(string $property): ?string; - public function setAdditionalProperty(string $property, string $value): self; - public function removeAdditionalProperty(string $property): bool; - -.. note:: - - The methods **setAdditionalProperty** and **removeAdditionalProperty** are only added if the `immutable setting <../gettingStarted.html#immutable-classes>`__ is set to false. - -**getAdditionalProperties**: This method returns all additional properties which are currently part of the model as key-value pairs where the key is the property name and the value the current value stored in the model. All other properties which are part of the object (in this case the property *example*) will not be included. In opposite to the *getRawModelDataInput* the values provided via this method are the processed values. This means if the schema provides an object-schema for additional properties an array of object instances will be returned. If the additional properties schema contains `filter <../nonStandardExtensions/filter.html>`__ the filtered (and in case of transforming filter transformed) values will be returned. - -**getAdditionalProperty**: Returns the current value of a single additional property. If the requested property doesn't exist null will be returned. Returns as well as *getAdditionalProperties* the processed values. - -**setAdditionalProperty**: Adds or updates an additional property. Performs all necessary validations like property names or min and max properties validations. If the additional properties are processed via a transforming filter an already transformed value will be accepted. If a property which is regularly defined in the schema a *RegularPropertyAsAdditionalPropertyException* will be thrown. If the change is valid and performed also the output of *getRawModelDataInput* will be updated. - -**removeAdditionalProperty**: Removes an existing additional property from the model. Returns true if the additional property has been removed, false otherwise (if no additional property with the requested key exists). May throw a *MinPropertiesException* if the change would result in an invalid model state. If the change is valid and performed also the output of *getRawModelDataInput* will be updated. - -Serialization -~~~~~~~~~~~~~ - -By default additional properties are only included in the serialized models if the *additionalProperties* field is set to true or contains further restrictions. If the option *$addForModelsWithoutAdditionalPropertiesDefinition* is set to true also additional properties for entities which don't define the *additionalProperties* field will be included in the serialization result. If the **AdditionalPropertiesAccessorPostProcessor** is applied and `serialization <../gettingStarted.html#serialization-methods>`__ is enabled the additional properties will be merged into the serialization result. If the additional properties are processed via a transforming filter each value will be serialized via the serialisation method of the transforming filter. - -PatternPropertiesAccessorPostProcessor -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: php - - $generator = new ModelGenerator(); - $generator->addPostProcessor(new PatternPropertiesAccessorPostProcessor()); - -The **PatternPropertiesAccessorPostProcessor** adds methods to your model to work with `pattern properties <../complexTypes/object.html#pattern-properties>`__ on your objects. The methods will only be added if the schema for the object defines pattern properties. - -Added methods -~~~~~~~~~~~~~ - -.. code-block:: json - - { - "$id": "example", - "type": "object", - "properties": { - "example": { - "type": "string" - } - }, - "patternProperties": { - "^a": { - "type": "string" - }, - "^b": { - "key": "numbers" - "type": "integer" - }, - } - } - -Generated interface with the **AdditionalPropertiesAccessorPostProcessor**: - -.. code-block:: php - - public function getRawModelDataInput(): array; - - public function setExample(float $example): self; - public function getExample(): float; - - public function getPatternProperties(string $key): array; - -The added method **getPatternProperties** can be used to fetch a list of all properties matching the given pattern. As *$key* you have to provide the pattern you want to fetch. Alternatively you can define a key in your schema and use the key to fetch the properties. - -.. code-block:: php - - $myObject = new Example('a1' => 'Hello', 'b1' => 100); - - // fetches all properties matching the pattern '^a', consequently will return ['a1' => 'Hello'] - $myObject->getPatternProperties('^a'); - - // fetches all properties matching the pattern '^b' (which has a defined key), consequently will return ['b1' => 100] - $myObject->getPatternProperties('numbers'); - -.. note:: - - If you want to modify your object by adding or removing pattern properties after the object instantiation you can use the `AdditionalPropertiesAccessorPostProcessor `__ or the `PopulatePostProcessor `__ - -EnumPostProcessor -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. warning:: - - Requires at least PHP 8.1 - -.. code-block:: php - - $generator = new ModelGenerator(); - $generator->addPostProcessor(new EnumPostProcessor(__DIR__ . '/generated/enum/', '\\MyApp\\Enum')); - -The **EnumPostProcessor** generates a `PHP enum `_ for each `enum <../complexTypes/enum.html>`__ found in the processed schemas. -Enums which contain only integer values or only string values will be rendered into a `backed enum `_. -Other enums will provide the following interface similar to the capabilities of a backed enum: - -.. code-block:: php - - public static function from(mixed $value): self; - public static function tryFrom(mixed $value): ?self; - - public function value(): mixed; - -Let's have a look at the most simple case of a string-only enum: - -.. code-block:: json - - { - "$id": "offer", - "type": "object", - "properties": { - "state": { - "enum": ["open", "sold", "cancelled"] - } - } - } - -The provided schema will generate the following enum: - -.. code-block:: php - - enum OfferState: string { - case Open = 'open'; - case Sold = 'sold'; - case Cancelled = 'cancelled'; - } - -The type hints and annotations of the generated class will be changed to match the generated enum: - -.. code-block:: php - - /** - * @param OfferState|string|null $state - */ - public function setState($state): self; - public function getState(): ?OfferState; - -Mapping -~~~~~~~ - -Each enum which is not a string-only enum must provide a mapping in the **enum-map** property, for example an integer-only enum: - -.. code-block:: json - - { - "$id": "offer", - "type": "object", - "properties": { - "state": { - "enum": [0, 1, 2], - "enum-map": { - "open": 0, - "sold": 1, - "cancelled": 2 - } - } - } - } - -The provided schema will generate the following enum: - -.. code-block:: php - - enum OfferState: int { - case Open = 0; - case Sold = 1; - case Cancelled = 2; - } - -If an enum which requires a mapping is found but no mapping is provided a **SchemaException** will be thrown. - -.. note:: - - By enabling the *$skipNonMappedEnums* option of the **EnumPostProcessor** you can skip enums which require a mapping but don't provide a mapping. Those enums will provide the default `enum <../complexTypes/enum.html>`__ behaviour. - -Custom Post Processors ----------------------- - -You can implement custom post processors to accomplish your tasks. Each post processor must extend the class **PHPModelGenerator\\SchemaProcessor\\PostProcessor\\PostProcessor**. If you have implemented a post processor add the post processor to your `ModelGenerator` and the post processor will be executed for each class. - -A custom post processor which adds a custom trait to the generated model (eg. a trait adding methods for an active record pattern implementation) may look like: - -.. code-block:: php - - namespace MyApp\Model\Generator\PostProcessor; - - use MyApp\Model\ActiveRecordTrait; - use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; - - class ActiveRecordPostProcessor extends PostProcessor - { - public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void - { - $schema->addTrait(ActiveRecordTrait::class); - } - } - -.. hint:: - - For examples how to implement a custom post processor have a look at the built in post processors located at **src/SchemaProcessor/PostProcessor/** - -What can you do inside your custom post processor? - -* Add additional traits and interfaces to your models -* Add additional methods and properties to your models -* Hook via **SchemaHooks** into the generated source code and add your snippets at defined places inside the model: - - * Implement the **ConstructorBeforeValidationHookInterface** to add code to the beginning of your constructor - * Implement the **ConstructorAfterValidationHookInterface** to add code to the end of your constructor - * Implement the **GetterHookInterface** to add code to your getter methods - * Implement the **SetterBeforeValidationHookInterface** to add code to the beginning of your setter methods - * Implement the **SetterAfterValidationHookInterface** to add code to the end of your setter methods - * Implement the **SerializationHookInterface** to add code to the end of your serialization process - -.. warning:: - - If a setter for a property is called with the same value which is already stored internally (consequently no update of the property is required), the setters will return directly and as a result of that the setter hooks will not be executed. - - This behaviour also applies also to properties changed via the *populate* method added by the `PopulatePostProcessor <#populatepostprocessor>`__ and the *setAdditionalProperty* method added by the `AdditionalPropertiesAccessorPostProcessor <#additionalpropertiesaccessorpostprocessor>`__ +.. toctree:: + :caption: Custom Post Processors + :maxdepth: 1 -To execute code before/after the processing of the schemas override the methods **preProcess** and **postProcess** inside your custom post processor. + custom/customPostProcessor diff --git a/docs/source/generic/meta.rst b/docs/source/generic/meta.rst index f9ddd66e..fb46d61f 100644 --- a/docs/source/generic/meta.rst +++ b/docs/source/generic/meta.rst @@ -59,8 +59,8 @@ Generated code in the PHP class: /** * Set the value of example. * - * @param string|null $example My example property with a large and very helpful description + * @param string $example My example property with a large and very helpful description * * @return self */ - public function setExample(?string $example): self; + public function setExample(string $example): static; diff --git a/docs/source/generic/readonly.rst b/docs/source/generic/readonly.rst index fd3b3680..fd053224 100644 --- a/docs/source/generic/readonly.rst +++ b/docs/source/generic/readonly.rst @@ -25,5 +25,5 @@ Generated interface (with immutability disabled): public function getName(): ?string; - public function setAge(?int $example): self; + public function setAge(int $example): static; public function getAge(): ?int; diff --git a/docs/source/generic/references.rst b/docs/source/generic/references.rst index ca6050c2..22ac8b1e 100644 --- a/docs/source/generic/references.rst +++ b/docs/source/generic/references.rst @@ -77,7 +77,7 @@ Generated interface: .. code-block:: php // class Citizen - public function setName(?string $name): self; + public function setName(string $name): static; public function getName(): ?string; If a base reference is used and the reference doesn't point to an object definition an Exception will be thrown during the model generation process: diff --git a/docs/source/generic/required.rst b/docs/source/generic/required.rst index d27559dd..240a2c09 100644 --- a/docs/source/generic/required.rst +++ b/docs/source/generic/required.rst @@ -21,7 +21,7 @@ Generated interface: .. code-block:: php - public function setExample(string $example): self; + public function setExample(string $example): static; // As the property is not required it may be initialized with null. Consequently the return value is nullable public function getExample(): ?string; @@ -63,7 +63,7 @@ Generated interface (type hints not nullable any longer): .. code-block:: php - public function setExample(string $example): self; + public function setExample(string $example): static; public function getExample(): string; Possible exceptions: diff --git a/docs/source/gettingStarted.rst b/docs/source/gettingStarted.rst index 446eeafe..f21066b2 100644 --- a/docs/source/gettingStarted.rst +++ b/docs/source/gettingStarted.rst @@ -282,7 +282,7 @@ Additionally the class will implement the PHP builtin interface **\JsonSerializa .. warning:: - If you provide `additional properties `__ you may want to use the `AdditionalPropertiesAccessorPostProcessor `__ as the additional properties by default aren't included into the serialization result. + If you provide `additional properties `__ you may want to use the `AdditionalPropertiesAccessorPostProcessor `__ as the additional properties by default aren't included into the serialization result. Output generation process ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/source/nonStandardExtensions/filter.rst b/docs/source/nonStandardExtensions/filter.rst index d23a25dd..22482ec1 100644 --- a/docs/source/nonStandardExtensions/filter.rst +++ b/docs/source/nonStandardExtensions/filter.rst @@ -207,7 +207,9 @@ Let's have a look how the generated model behaves: dateTime ^^^^^^^^ -The dateTime filter is only valid for string and null properties. +The dateTime filter is only valid for string, number, float and nullable properties. +Number and float values will be handled as timestamps. +With the type of your property, you can limit the possible inputs, e.g. to accept only strings: .. code-block:: json @@ -230,9 +232,9 @@ Generated interface: .. code-block:: php - // $productionDate accepts string|DateTime|null + // $productionDate accepts string|DateTime // if a string is provided the string will be transformed into a DateTime - public function setProductionDate($productionDate): self; + public function setProductionDate($productionDate): static; public function getProductionDate(): ?DateTime; Let's have a look how the generated model behaves: diff --git a/docs/source/toc-generator.rst b/docs/source/toc-generator.rst index beb5f7b6..7ce90705 100644 --- a/docs/source/toc-generator.rst +++ b/docs/source/toc-generator.rst @@ -1,5 +1,5 @@ .. toctree:: :caption: Extending the generator - :maxdepth: 1 + :maxdepth: 2 generator/postProcessor diff --git a/docs/source/types/boolean.rst b/docs/source/types/boolean.rst index 4b024fe0..0fe84b1b 100644 --- a/docs/source/types/boolean.rst +++ b/docs/source/types/boolean.rst @@ -19,7 +19,7 @@ Generated interface: .. code-block:: php - public function setExample(bool $example): self; + public function setExample(bool $example): static; // As the property is not required it may be initialized with null. Consequently the return value is nullable public function getExample(): ?bool; diff --git a/docs/source/types/const.rst b/docs/source/types/const.rst index 3a144e77..fea148ab 100644 --- a/docs/source/types/const.rst +++ b/docs/source/types/const.rst @@ -19,7 +19,7 @@ Generated interface (the typehint is auto-detected from the given constant value .. code-block:: php - public function setExample(int $example): self; + public function setExample(int $example): static; public function getExample(): int; Possible exceptions: diff --git a/docs/source/types/null.rst b/docs/source/types/null.rst index cf387175..bd6ba52d 100644 --- a/docs/source/types/null.rst +++ b/docs/source/types/null.rst @@ -19,7 +19,7 @@ Generated interface (as null is no explicit type no typehints are generated): .. code-block:: php - public function setExample($example): self; + public function setExample($example): static; public function getExample(); Possible exceptions: diff --git a/docs/source/types/number.rst b/docs/source/types/number.rst index 436d7869..4721f85a 100644 --- a/docs/source/types/number.rst +++ b/docs/source/types/number.rst @@ -22,11 +22,11 @@ Generated interface: .. code-block:: php - public function setExample1(int $example): self; + public function setExample1(int $example): static; // As the property is not required it may be initialized with null. Consequently the return value is nullable public function getExample1(): ?int; - public function setExample2(float $example): self; + public function setExample2(float $example): static; public function getExample2(): ?float; Possible exceptions: diff --git a/docs/source/types/string.rst b/docs/source/types/string.rst index 12d78548..cd7a134e 100644 --- a/docs/source/types/string.rst +++ b/docs/source/types/string.rst @@ -19,7 +19,7 @@ Generated interface: .. code-block:: php - public function setExample(string $example): self; + public function setExample(string $example): static; // As the property is not required it may be initialized with null. Consequently the return value is nullable public function getExample(): ?string; diff --git a/src/Model/Property/Property.php b/src/Model/Property/Property.php index dd37c1d6..e3e7fb6c 100644 --- a/src/Model/Property/Property.php +++ b/src/Model/Property/Property.php @@ -146,6 +146,7 @@ public function getTypeHint(bool $outputType = false, array $skipDecorators = [] public function addTypeHintDecorator(TypeHintDecoratorInterface $typeHintDecorator): PropertyInterface { $this->typeHintDecorators[] = $typeHintDecorator; + $this->renderedTypeHints = []; return $this; } diff --git a/src/Model/RenderJob.php b/src/Model/RenderJob.php index 1cc9ca48..f267a7a3 100644 --- a/src/Model/RenderJob.php +++ b/src/Model/RenderJob.php @@ -24,22 +24,16 @@ class RenderJob /** * Create a new class render job * - * @param string $fileName The file name - * @param string $classPath The relative path of the class for namespace generation - * @param string $className The class name - * @param Schema $schema The Schema object which holds properties and validators + * @param Schema $schema The Schema object which holds properties and validators */ public function __construct( - protected string $fileName, - protected string $classPath, - protected string $className, protected Schema $schema, ) {} /** * @param PostProcessor[] $postProcessors */ - public function postProcess(array $postProcessors, GeneratorConfiguration $generatorConfiguration): void + public function executePostProcessors(array $postProcessors, GeneratorConfiguration $generatorConfiguration): void { foreach ($postProcessors as $postProcessor) { $postProcessor->process($this->schema, $generatorConfiguration); @@ -58,23 +52,33 @@ public function render(GeneratorConfiguration $generatorConfiguration): void $class = $this->renderClass($generatorConfiguration); - if (file_exists($this->fileName)) { - throw new FileSystemException("File {$this->fileName} already exists. Make sure object IDs are unique."); + if (file_exists($this->schema->getTargetFileName())) { + throw new FileSystemException( + "File {$this->schema->getTargetFileName()} already exists. Make sure object IDs are unique.", + ); } - if (!file_put_contents($this->fileName, $class)) { + if (!file_put_contents($this->schema->getTargetFileName(), $class)) { // @codeCoverageIgnoreStart - throw new FileSystemException("Can't write class $this->classPath\\$this->className."); + throw new FileSystemException( + "Can't write class {$this->schema->getClassPath()}\\{$this->schema->getClassName()}.", + ); // @codeCoverageIgnoreEnd } + require $this->schema->getTargetFileName(); + if ($generatorConfiguration->isOutputEnabled()) { echo sprintf( "Rendered class %s\n", join( '\\', - array_filter([$generatorConfiguration->getNamespacePrefix(), $this->classPath, $this->className]), - ) + array_filter([ + $generatorConfiguration->getNamespacePrefix(), + $this->schema->getClassPath(), + $this->schema->getClassName(), + ]), + ), ); } } @@ -86,9 +90,11 @@ public function render(GeneratorConfiguration $generatorConfiguration): void */ protected function generateModelDirectory(): void { - $destination = dirname($this->fileName); + $destination = dirname($this->schema->getTargetFileName()); if (!is_dir($destination) && !mkdir($destination, 0777, true)) { + // @codeCoverageIgnoreStart throw new FileSystemException("Can't create path $destination"); + // @codeCoverageIgnoreEnd } } @@ -99,7 +105,10 @@ protected function generateModelDirectory(): void */ protected function renderClass(GeneratorConfiguration $generatorConfiguration): string { - $namespace = trim(join('\\', [$generatorConfiguration->getNamespacePrefix(), $this->classPath]), '\\'); + $namespace = trim( + join('\\', [$generatorConfiguration->getNamespacePrefix(), $this->schema->getClassPath()]), + '\\', + ); try { $class = (new Render(__DIR__ . '/../Templates/'))->renderTemplate( @@ -107,7 +116,6 @@ protected function renderClass(GeneratorConfiguration $generatorConfiguration): [ 'namespace' => $namespace, 'use' => $this->getUseForSchema($generatorConfiguration, $namespace), - 'class' => $this->className, 'schema' => $this->schema, 'schemaHookResolver' => new SchemaHookResolver($this->schema), 'generatorConfiguration' => $generatorConfiguration, @@ -121,7 +129,13 @@ protected function renderClass(GeneratorConfiguration $generatorConfiguration): ], ); } catch (PHPMicroTemplateException $exception) { - throw new RenderException("Can't render class $this->classPath\\$this->className", 0, $exception); + // @codeCoverageIgnoreStart + throw new RenderException( + "Can't render class {$this->schema->getClassPath()}\\{$this->schema->getClassName()}", + 0, + $exception, + ); + // @codeCoverageIgnoreEnd } return $class; @@ -132,21 +146,16 @@ protected function renderClass(GeneratorConfiguration $generatorConfiguration): */ protected function getUseForSchema(GeneratorConfiguration $generatorConfiguration, string $namespace): array { - $use = array_unique( - array_merge( - $this->schema->getUsedClasses(), - $generatorConfiguration->collectErrors() - ? [$generatorConfiguration->getErrorRegistryClass()] - : [ValidationException::class], - ) + return RenderHelper::filterClassImports( + array_unique( + array_merge( + $this->schema->getUsedClasses(), + $generatorConfiguration->collectErrors() + ? [$generatorConfiguration->getErrorRegistryClass()] + : [ValidationException::class], + ), + ), + $namespace, ); - - // filter out non-compound uses and uses which link to the current namespace - $use = array_filter($use, static fn($classPath): bool => - strstr(trim(str_replace("$namespace", '', $classPath), '\\'), '\\') || - (!strstr($classPath, '\\') && !empty($namespace)), - ); - - return $use; } } diff --git a/src/Model/Schema.php b/src/Model/Schema.php index ba5a684c..62e057f4 100644 --- a/src/Model/Schema.php +++ b/src/Model/Schema.php @@ -57,6 +57,7 @@ class Schema * Schema constructor. */ public function __construct( + protected string $targetFileName, protected string $classPath, protected string $className, JsonSchema $schema, @@ -70,6 +71,11 @@ public function __construct( $this->addInterface(JSONModelInterface::class); } + public function getTargetFileName(): string + { + return $this->targetFileName; + } + public function getClassName(): string { return $this->className; diff --git a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php index c98016e9..18f3cee6 100644 --- a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php +++ b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php @@ -151,6 +151,7 @@ protected function parseExternalFile( // set up a dummy schema to fetch the definitions from the external file $schema = new Schema( + '', $schemaProcessor->getCurrentClassPath(), 'ExternalSchema', new JsonSchema($jsonSchemaFilePath, $decodedJsonSchema), diff --git a/src/PropertyProcessor/Filter/DateTimeFilter.php b/src/PropertyProcessor/Filter/DateTimeFilter.php index e9bc8997..3172d6cd 100644 --- a/src/PropertyProcessor/Filter/DateTimeFilter.php +++ b/src/PropertyProcessor/Filter/DateTimeFilter.php @@ -21,7 +21,7 @@ class DateTimeFilter implements TransformingFilterInterface */ public function getAcceptedTypes(): array { - return ['string', 'null']; + return ['integer', 'string', 'number', 'null']; } /** diff --git a/src/PropertyProcessor/Property/AbstractValueProcessor.php b/src/PropertyProcessor/Property/AbstractValueProcessor.php index d47c0014..1cee5464 100644 --- a/src/PropertyProcessor/Property/AbstractValueProcessor.php +++ b/src/PropertyProcessor/Property/AbstractValueProcessor.php @@ -53,9 +53,16 @@ public function process(string $propertyName, JsonSchema $propertySchema): Prope ->setRequired($this->propertyMetaDataCollection->isAttributeRequired($propertyName)) ->setReadOnly( (isset($json['readOnly']) && $json['readOnly'] === true) || + (isset($json['readonly']) && $json['readonly'] === true) || $this->schemaProcessor->getGeneratorConfiguration()->isImmutable(), ); + if (isset($json['readOnly'])) { + // @codeCoverageIgnoreStart + trigger_error('Change from readOnly to readonly for property "' . $propertyName . '".', E_USER_DEPRECATED); + // @codeCoverageIgnoreEnd + } + $this->generateValidators($property, $propertySchema); if (isset($json['filter'])) { diff --git a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php new file mode 100644 index 00000000..0386405e --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php @@ -0,0 +1,147 @@ +schemas[] = $schema; + $this->generatorConfiguration ??= $generatorConfiguration; + $this->renderHelper ??= new RenderHelper($generatorConfiguration); + } + + public function preProcess(): void + { + $this->generatorConfiguration = null; + $this->renderHelper = null; + } + + public function postProcess(): void + { + parent::postProcess(); + + foreach ($this->schemas as $schema) { + $properties = []; + foreach ($schema->getProperties() as $property) { + if (!$property->isInternal()) { + $properties[] = (clone $property) + ->setReadOnly(false) + ->addTypeHintDecorator(new TypeHintTransferDecorator($property)) + ->filterValidators(static fn(Validator $validator): bool => false); + } + } + + $namespace = trim( + join('\\', [$this->generatorConfiguration->getNamespacePrefix(), $schema->getClassPath()]), + '\\', + ); + + $result = file_put_contents( + $filename = str_replace('.php', 'Builder.php', $schema->getTargetFileName()), + (new Render(__DIR__ . DIRECTORY_SEPARATOR . 'Templates' . DIRECTORY_SEPARATOR))->renderTemplate( + 'BuilderClass.phptpl', + [ + 'namespace' => $namespace, + 'class' => $schema->getClassName(), + 'schema' => $schema, + 'properties' => $properties, + 'use' => $this->getBuilderClassImports($properties, $schema->getUsedClasses(), $namespace), + 'generatorConfiguration' => $this->generatorConfiguration, + 'viewHelper' => new RenderHelper($this->generatorConfiguration), + ], + ) + ); + + $fqcn = "$namespace\\{$schema->getClassName()}Builder"; + + if ($result === false) { + // @codeCoverageIgnoreStart + throw new FileSystemException("Can't write builder class $fqcn.",); + // @codeCoverageIgnoreEnd + } + + require $filename; + + if ($this->generatorConfiguration->isOutputEnabled()) { + // @codeCoverageIgnoreStart + echo "Rendered builder class $fqcn\n"; + // @codeCoverageIgnoreEnd + } + } + } + + /** + * @param PropertyInterface[] $properties + * + * @return string[] + */ + private function getBuilderClassImports(array $properties, array $originalClassImports, string $namespace): array + { + $imports = [BuilderInterface::class]; + $imports[] = $this->generatorConfiguration->collectErrors() + ? $this->generatorConfiguration->getErrorRegistryClass() + : ValidationException::class; + + foreach ($properties as $property) { + // use typehint instead of type to cover multi-types + foreach (array_unique([ + ...explode('|', $this->renderHelper->getTypeHintAnnotation($property)), + ...explode('|', $this->renderHelper->getTypeHintAnnotation($property, true)), + ]) as $typeAnnotation) { + $type = str_replace('[]', '', $typeAnnotation); + + // as the typehint only knows the class name but not the fqcn, lookup in the original imports + foreach ($originalClassImports as $originalClassImport) { + if (str_ends_with($originalClassImport, "\\$type")) { + $type = $originalClassImport; + } + } + + if (class_exists($type) || enum_exists($type)) { + $imports[] = $type; + + // for nested objects, allow additionally to pass an instance of the nested model also just plain + // arrays which will result in an object instantiation and validation during the build process + if (in_array(JSONModelInterface::class, class_implements($type))) { + $builderClassName = (new ReflectionClass($type))->getShortName() . 'Builder'; + $property->addTypeHintDecorator(new TypeHintDecorator( + [$builderClassName . (str_contains($typeAnnotation, '[]') ? '[]' : ''), 'array'], + )); + + $property->setType(); + + $imports[] = $type . 'Builder'; + } + } + } + } + + return RenderHelper::filterClassImports(array_unique($imports), $namespace); + } +} diff --git a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php index e3915c8c..80b618df 100644 --- a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php @@ -6,6 +6,7 @@ use Exception; use PHPMicroTemplate\Render; +use PHPModelGenerator\Exception\FileSystemException; use PHPModelGenerator\Exception\Generic\InvalidTypeException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Filter\TransformingFilterInterface; @@ -255,8 +256,8 @@ private function renderEnum( $name .= '_1'; } - file_put_contents( - $this->targetDirectory . DIRECTORY_SEPARATOR . $name . '.php', + $result = file_put_contents( + $filename = $this->targetDirectory . DIRECTORY_SEPARATOR . $name . '.php', $this->renderer->renderTemplate( 'Enum.phptpl', [ @@ -270,6 +271,14 @@ private function renderEnum( $fqcn = "$this->namespace\\$name"; + if ($result === false) { + // @codeCoverageIgnoreStart + throw new FileSystemException("Can't write enum $fqcn."); + // @codeCoverageIgnoreEnd + } + + require $filename; + if ($generatorConfiguration->isOutputEnabled()) { // @codeCoverageIgnoreStart echo "Rendered enum $fqcn\n"; diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperties.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperties.phptpl index a93269a2..46e7f8b5 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperties.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperties.phptpl @@ -1,7 +1,7 @@ /** * Get additional properties * - * @return {% if validationProperty %}{% if viewHelper.getTypeHintAnnotation(validationProperty, true) %}{{ viewHelper.getTypeHintAnnotation(validationProperty, true) }}{% endif %}{% else %}array{% endif %} + * @return {% if validationProperty and viewHelper.getTypeHintAnnotation(validationProperty, true) %}{{ viewHelper.getTypeHintAnnotation(validationProperty, true) }}{% else %}array{% endif %} */ public function getAdditionalProperties(): array { diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperty.phptpl index 4e11091c..c5701a2b 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperty.phptpl @@ -3,9 +3,9 @@ * * @param string $property The key of the additional property * - * {% if validationProperty %}{% if viewHelper.getTypeHintAnnotation(validationProperty, true) %}@return {{ viewHelper.getTypeHintAnnotation(validationProperty, true) }}{% endif %}{% else %}@return mixed{% endif %} + * {% if validationProperty and viewHelper.getTypeHintAnnotation(validationProperty, true) %}@return {{ viewHelper.getTypeHintAnnotation(validationProperty, true) }}{% else %}@return mixed{% endif %} */ -public function getAdditionalProperty(string $property){% if validationProperty %}{% if validationProperty.getType(true) %}: {{ viewHelper.getType(validationProperty, true) }}{% endif %}{% endif %} +public function getAdditionalProperty(string $property){% if validationProperty and validationProperty.getType(true) %}: {{ viewHelper.getType(validationProperty, true) }}{% endif %} { return $this->_additionalProperties[$property] ?? null; } diff --git a/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl b/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl new file mode 100644 index 00000000..7a9f079e --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl @@ -0,0 +1,114 @@ +_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + } + {% endif %} + + /** + * Get the raw input used to set up the model + * + * @return array + */ + public function getRawModelDataInput(): array + { + return $this->_rawModelDataInput; + } + + /** + * Set up a new instance of {{ class }} with fully validated properties + */ + public function validate(): {{ class }} + { + array_walk_recursive( + $this->_rawModelDataInput, + function (&$property): void { + if ($property instanceof BuilderInterface) { + $property = $property->getRawModelDataInput(); + } + }, + ); + + return new {{ class }}($this->_rawModelDataInput); + } + + {% foreach properties as property %} + {% if not property.isInternal() %} + /** + * Get the value of {{ property.getName() }}. + * + * {% if property.getDescription() %}{{ property.getDescription() }}{% endif %} + * + * {% if viewHelper.getTypeHintAnnotation(property) %}@return {{ viewHelper.getTypeHintAnnotation(property, false, true) }}{% endif %} + */ + public function get{{ viewHelper.ucfirst(property.getAttribute()) }}() + {% if property.getType() %}: {{ viewHelper.getType(property, false, true) }}{% endif %} + { + return $this->_rawModelDataInput['{{ property.getName() }}'] ?? null; + } + + /** + * Set the value of {{ property.getName() }}. + * + * @param {{ viewHelper.getTypeHintAnnotation(property) }} ${{ property.getAttribute(true) }}{% if property.getDescription() %} {{ property.getDescription() }}{% endif %} + * + * {% if property.getValidators() %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} + * + * @return static + */ + public function set{{ viewHelper.ucfirst(property.getAttribute()) }}( + {{ viewHelper.getType(property) }} ${{ property.getAttribute(true) }} + ): static { + if (array_key_exists('{{ property.getName() }}', $this->_rawModelDataInput) + && $this->_rawModelDataInput['{{ property.getName() }}'] === ${{ property.getAttribute(true) }} + ) { + return $this; + } + + $value = $modelData['{{ property.getName() }}'] = ${{ property.getAttribute(true) }}; + + {% foreach property.getOrderedValidators() as validator %} + {{ viewHelper.renderValidator(validator, schema) }} + {% endforeach %} + + {% if property.getOrderedValidators() and generatorConfiguration.collectErrors() %} + if ($this->_errorRegistry->getErrors()) { + throw $this->_errorRegistry; + } + {% endif %} + + $this->_rawModelDataInput['{{ property.getName() }}'] = $value; + + return $this; + } + {% endif %} + {% endforeach %} +} + +// @codeCoverageIgnoreEnd diff --git a/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl index 7e54d783..d2e1e43b 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl @@ -62,12 +62,10 @@ public function populate(array $modelData): self $this->_rawModelDataInput = array_merge($this->_rawModelDataInput, $modelData); {% foreach schema.getProperties() as property %} - {% if not property.isInternal() %} - {% if schemaHookResolver.resolveSetterAfterValidationHook(property) %} - if (array_key_exists('{{ property.getName() }}', $rollbackValues)) { - {{ schemaHookResolver.resolveSetterAfterValidationHook(property, true) }} - } - {% endif %} + {% if not property.isInternal() and schemaHookResolver.resolveSetterAfterValidationHook(property) %} + if (array_key_exists('{{ property.getName() }}', $rollbackValues)) { + {{ schemaHookResolver.resolveSetterAfterValidationHook(property, true) }} + } {% endif %} {% endforeach %} diff --git a/src/SchemaProcessor/RenderQueue.php b/src/SchemaProcessor/RenderQueue.php index eea83be0..ef8cf4ec 100644 --- a/src/SchemaProcessor/RenderQueue.php +++ b/src/SchemaProcessor/RenderQueue.php @@ -44,7 +44,7 @@ public function execute(GeneratorConfiguration $generatorConfiguration, array $p } foreach ($this->jobs as $job) { - $job->postProcess($postProcessors, $generatorConfiguration); + $job->executePostProcessors($postProcessors, $generatorConfiguration); $job->render($generatorConfiguration); } diff --git a/src/SchemaProcessor/SchemaProcessor.php b/src/SchemaProcessor/SchemaProcessor.php index 7e215563..4db3a79d 100644 --- a/src/SchemaProcessor/SchemaProcessor.php +++ b/src/SchemaProcessor/SchemaProcessor.php @@ -123,7 +123,14 @@ protected function generateModel( return $this->processedSchema[$schemaSignature]; } - $schema = new Schema($classPath, $className, $jsonSchema, $dictionary, $initialClass); + $schema = new Schema( + $this->getTargetFileName($classPath, $className), + $classPath, + $className, + $jsonSchema, + $dictionary, + $initialClass, + ); $this->processedSchema[$schemaSignature] = $schema; $json = $jsonSchema->getJson(); @@ -137,7 +144,7 @@ protected function generateModel( $jsonSchema->withJson($json), ); - $this->generateClassFile($classPath, $className, $schema); + $this->generateClassFile($schema); return $schema; } @@ -145,26 +152,24 @@ protected function generateModel( /** * Attach a new class file render job to the render proxy */ - public function generateClassFile( - string $classPath, - string $className, - Schema $schema, - ): void { - $fileName = join( - DIRECTORY_SEPARATOR, - array_filter([$this->destination, str_replace('\\', DIRECTORY_SEPARATOR, $classPath), $className]), - ) . '.php'; - - $this->renderQueue->addRenderJob(new RenderJob($fileName, $classPath, $className, $schema)); + public function generateClassFile(Schema $schema): void { + $this->renderQueue->addRenderJob(new RenderJob($schema)); if ($this->generatorConfiguration->isOutputEnabled()) { echo sprintf( "Generated class %s\n", - join('\\', array_filter([$this->generatorConfiguration->getNamespacePrefix(), $classPath, $className])), + join( + '\\', + array_filter([ + $this->generatorConfiguration->getNamespacePrefix(), + $schema->getClassPath(), + $schema->getClassName(), + ]), + ), ); } - $this->generatedFiles[] = $fileName; + $this->generatedFiles[] = $schema->getTargetFileName(); } @@ -205,7 +210,12 @@ public function createMergedProperty( $this->getCurrentClassName(), ); - $mergedPropertySchema = new Schema($schema->getClassPath(), $mergedClassName, $propertySchema); + $mergedPropertySchema = new Schema( + $this->getTargetFileName($schema->getClassPath(), $mergedClassName), + $schema->getClassPath(), + $mergedClassName, + $propertySchema, + ); $this->processedMergedProperties[$schemaSignature] = (new Property( 'MergedProperty', @@ -220,7 +230,7 @@ public function createMergedProperty( // make sure the merged schema knows all imports of the parent schema $mergedPropertySchema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($schema)); - $this->generateClassFile($this->getCurrentClassPath(), $mergedClassName, $mergedPropertySchema); + $this->generateClassFile($mergedPropertySchema); } $mergedSchema = $this->processedMergedProperties[$schemaSignature]->getNestedSchema(); @@ -329,4 +339,12 @@ public function getGeneratorConfiguration(): GeneratorConfiguration { return $this->generatorConfiguration; } + + private function getTargetFileName(string $classPath, string $className): string + { + return join( + DIRECTORY_SEPARATOR, + array_filter([$this->destination, str_replace('\\', DIRECTORY_SEPARATOR, $classPath), $className]), + ) . '.php'; + } } diff --git a/src/Templates/Model.phptpl b/src/Templates/Model.phptpl index 2d4d8b1d..e39354c0 100644 --- a/src/Templates/Model.phptpl +++ b/src/Templates/Model.phptpl @@ -14,7 +14,7 @@ declare(strict_types = 1); {% endforeach %} /** - * Class {{ class }} + * Class {{ schema.getClassName() }} {% if namespace %} * @package {{ namespace }} {% endif %} * {% if schema.getDescription() %} * {{ schema.getDescription() }} @@ -23,7 +23,7 @@ declare(strict_types = 1); * If you need to implement something in this class use inheritance. Else you will lose your changes if the classes * are re-generated. */ -class {{ class }} {% if schema.getInterfaces() %}implements {{ viewHelper.joinClassNames(schema.getInterfaces()) }}{% endif %} +class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ viewHelper.joinClassNames(schema.getInterfaces()) }}{% endif %} { {% if schema.getTraits() %}use {{ viewHelper.joinClassNames(schema.getTraits()) }};{% endif %} @@ -40,7 +40,7 @@ class {{ class }} {% if schema.getInterfaces() %}implements {{ viewHelper.joinCl {% endif %} /** - * {{ class }} constructor. + * {{ schema.getClassName() }} constructor. * * @param array $rawModelDataInput * @@ -125,11 +125,11 @@ class {{ class }} {% if schema.getInterfaces() %}implements {{ viewHelper.joinCl * * {% if property.getValidators() %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} * - * @return self + * @return static */ public function set{{ viewHelper.ucfirst(property.getAttribute()) }}( {{ viewHelper.getType(property) }} ${{ property.getAttribute(true) }} - ): self { + ): static { if ($this->{{ property.getAttribute(true) }} === ${{ property.getAttribute(true) }}) { return $this; } @@ -168,12 +168,10 @@ class {{ class }} {% if schema.getInterfaces() %}implements {{ viewHelper.joinCl */ protected function process{{ viewHelper.ucfirst(property.getAttribute()) }}(array $modelData): void { - {% if not generatorConfiguration.isImplicitNullAllowed() %} - {% if not property.isRequired() %} - if (!array_key_exists('{{ property.getName() }}', $modelData) && $this->{{ property.getAttribute(true) }} === null) { - return; - } - {% endif %} + {% if not generatorConfiguration.isImplicitNullAllowed() and not property.isRequired() %} + if (!array_key_exists('{{ property.getName() }}', $modelData) && $this->{{ property.getAttribute(true) }} === null) { + return; + } {% endif %} $value = array_key_exists('{{ property.getName() }}', $modelData) ? $modelData['{{ property.getName() }}'] : $this->{{ property.getAttribute(true) }}; diff --git a/src/Templates/Validator/ComposedItem.phptpl b/src/Templates/Validator/ComposedItem.phptpl index 58b69e38..56a103eb 100644 --- a/src/Templates/Validator/ComposedItem.phptpl +++ b/src/Templates/Validator/ComposedItem.phptpl @@ -100,12 +100,12 @@ } {% endif %} } catch (\Exception $e) { - {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} - {% if not generatorConfiguration.collectErrors() %} - if (isset($validatorIndex)) { - $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex] = false; - } - {% endif %} + {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) + and not generatorConfiguration.collectErrors() + %} + if (isset($validatorIndex)) { + $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex] = false; + } {% endif %} {% foreach compositionProperty.getAffectedObjectProperties() as affectedObjectProperty %} diff --git a/src/Utils/RenderHelper.php b/src/Utils/RenderHelper.php index 86022c34..424b659a 100644 --- a/src/Utils/RenderHelper.php +++ b/src/Utils/RenderHelper.php @@ -88,7 +88,7 @@ public function isPropertyNullable(PropertyInterface $property, bool $outputType && !($property->getDefaultValue() !== null && !$this->generatorConfiguration->isImplicitNullAllowed()); } - public function getType(PropertyInterface $property, bool $outputType = false): string + public function getType(PropertyInterface $property, bool $outputType = false, bool $forceNullable = false): string { $type = $property->getType($outputType); @@ -96,18 +96,24 @@ public function getType(PropertyInterface $property, bool $outputType = false): return ''; } - $nullable = ($type->isNullable() ?? $this->isPropertyNullable($property, $outputType)) ? '?' : ''; + $nullable = ($type->isNullable() ?? $this->isPropertyNullable($property, $outputType)) || $forceNullable + ? '?' + : ''; return "$nullable{$type->getName()}"; } - public function getTypeHintAnnotation(PropertyInterface $property, bool $outputType = false): string - { + public function getTypeHintAnnotation( + PropertyInterface $property, + bool $outputType = false, + bool $forceNullable = false, + ): string { $typeHint = $property->getTypeHint($outputType); $hasDefinedNullability = ($type = $property->getType($outputType)) && $type->isNullable() !== null; if ((($hasDefinedNullability && $type->isNullable()) || (!$hasDefinedNullability && $this->isPropertyNullable($property, $outputType)) + || $forceNullable ) && !strstr($typeHint, 'mixed') ) { $typeHint = "$typeHint|null"; @@ -161,4 +167,13 @@ public static function varExportArray(array $values): string { return preg_replace('(\d+\s=>)', '', var_export($values, true)); } + + public static function filterClassImports(array $imports, string $namespace): array + { + // filter out non-compound uses and uses which link to the current namespace + return array_filter($imports, static fn($classPath): bool => + strstr(trim(str_replace("$namespace", '', $classPath), '\\'), '\\') || + (!strstr($classPath, '\\') && !empty($namespace)), + ); + } } diff --git a/tests/AbstractPHPModelGeneratorTestCase.php b/tests/AbstractPHPModelGeneratorTestCase.php index 78d4ada9..48ec2ac2 100644 --- a/tests/AbstractPHPModelGeneratorTestCase.php +++ b/tests/AbstractPHPModelGeneratorTestCase.php @@ -260,8 +260,6 @@ public function getClassName( foreach ($generatedFiles as $path) { $this->generatedFiles[] = $path; - - require $path; } return $className; @@ -276,15 +274,18 @@ public function getClassName( */ protected function generateDirectory(string $directory, GeneratorConfiguration $configuration): array { - $generatedClasses = (new ModelGenerator($configuration))->generateModels( + $generator = new ModelGenerator($configuration); + if (is_callable($this->modifyModelGenerator)) { + ($this->modifyModelGenerator)($generator); + } + + $generatedClasses = $generator->generateModels( new RecursiveDirectoryProvider(__DIR__ . '/Schema/' . $this->getStaticClassName() . '/' . $directory), MODEL_TEMP_PATH, ); foreach ($generatedClasses as $path) { $this->generatedFiles[] = $path; - - require $path; } return $generatedClasses; @@ -336,7 +337,7 @@ protected function expectValidationErrorRegExp( if ($configuration->collectErrors()) { $this->expectException(ErrorRegistryException::class); - $this->expectExceptionMessageMatches(join("\n", $messages)); + $this->expectExceptionMessageMatches(str_replace("/\n/", "\n", join("\n", $messages))); } else { $this->expectException(ValidationException::class); $this->expectExceptionMessageMatches($messages[0]); diff --git a/tests/PostProcessor/BuilderClassPostProcessorTest.php b/tests/PostProcessor/BuilderClassPostProcessorTest.php new file mode 100644 index 00000000..cba6e359 --- /dev/null +++ b/tests/PostProcessor/BuilderClassPostProcessorTest.php @@ -0,0 +1,228 @@ +modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new BuilderClassPostProcessor()); + }; + } + + public function testBuilder(): void + { + $className = $this->generateClassFromFile( + 'BasicSchema.json', + (new GeneratorConfiguration())->setSerialization(true), + implicitNull: false, + ); + + $this->assertGeneratedBuilders(1); + + $builderClassName = $className . 'Builder'; + $builderObject = new $builderClassName(); + + $this->assertNull($builderObject->getName()); + $this->assertNull($builderObject->getAge()); + + $builderObject->setName('Albert'); + $builderObject->setAge(65); + + $this->assertSame('Albert', $builderObject->getName()); + $this->assertSame(65, $builderObject->getAge()); + $this->assertEqualsCanonicalizing(['name' => 'Albert', 'age' => 65], $builderObject->getRawModelDataInput()); + + $this->assertSame('string', $this->getParameterTypeAnnotation($builderObject, 'setName')); + $this->assertSame('int', $this->getParameterTypeAnnotation($builderObject, 'setAge')); + $this->assertSame('string|null', $this->getReturnTypeAnnotation($builderObject, 'getName')); + $this->assertSame('int|null', $this->getReturnTypeAnnotation($builderObject, 'getAge')); + + $returnType = $this->getReturnType($builderObject, 'getName'); + $this->assertSame('string', $returnType->getName()); + $this->assertTrue($returnType->allowsNull()); + + $returnType = $this->getReturnType($builderObject, 'getAge'); + $this->assertSame('int', $returnType->getName()); + $this->assertTrue($returnType->allowsNull()); + + $validatedObject = $builderObject->validate(); + + $this->assertInstanceOf($className, $validatedObject); + $this->assertSame('Albert', $validatedObject->getName()); + $this->assertSame(65, $validatedObject->getAge()); + $this->assertEqualsCanonicalizing(['name' => 'Albert', 'age' => 65], $validatedObject->getRawModelDataInput()); + $this->assertEqualsCanonicalizing(['name' => 'Albert', 'age' => 65], $validatedObject->toArray()); + } + + /** + * @dataProvider validationMethodDataProvider + */ + public function testInvalidBuilderDataThrowsAnExceptionOnValidate(GeneratorConfiguration $configuration): void + { + $className = $this->generateClassFromFile('BasicSchema.json', $configuration); + + $builderClassName = $className . 'Builder'; + $builderObject = new $builderClassName(); + + $builderObject->setName('Al')->setAge(-2); + + $this->expectValidationErrorRegExp( + $configuration, + [ + '/Value for name must not be shorter than 5/', + '/Value for age must not be smaller than 0/' + ], + ); + + $builderObject->validate(); + } + + public function testImplicitNull(): void + { + $className = $this->generateClassFromFile('BasicSchema.json'); + + $builderClassName = $className . 'Builder'; + $builderObject = new $builderClassName(); + + $this->assertSame('string', $this->getParameterTypeAnnotation($builderObject, 'setName')); + $this->assertSame('int|null', $this->getParameterTypeAnnotation($builderObject, 'setAge')); + $this->assertSame('string|null', $this->getReturnTypeAnnotation($builderObject, 'getName')); + $this->assertSame('int|null', $this->getReturnTypeAnnotation($builderObject, 'getAge')); + } + + public function testNestedObject(): void + { + $files = $this->generateDirectory( + 'NestedObject', + (new GeneratorConfiguration()) + ->setNamespacePrefix('MyApp\\Namespace\\') + ->setOutputEnabled(false) + ->setImplicitNull(true), + ); + + $this->assertCount(2, $files); + $this->assertGeneratedBuilders(2); + + $builderClassName = 'MyApp\Namespace\NestedObjectBuilder'; + $builderObject = new $builderClassName(); + + $expectedTypeHint = "Address|AddressBuilder|array|null"; + $this->assertSame($expectedTypeHint, $this->getParameterTypeAnnotation($builderObject, 'setAddress')); + $this->assertSame($expectedTypeHint, $this->getReturnTypeAnnotation($builderObject, 'getAddress')); + + // test generate nested object from array + $addressArray = ['street' => 'Test street', 'number' => 10]; + $builderObject->setAddress($addressArray); + $this->assertSame($addressArray, $builderObject->getAddress()); + $this->assertSame(['address' => $addressArray], $builderObject->getRawModelDataInput()); + $object = $builderObject->validate(); + $this->assertSame('Test street', $object->getAddress()->getStreet()); + $this->assertSame(10, $object->getAddress()->getNumber()); + + // test generate nested object from nested builder + $nestedBuilderClassName = 'MyApp\Namespace\Dependencies\AddressBuilder'; + $nestedBuilderObject = new $nestedBuilderClassName(); + $this->assertSame('string|null', $this->getParameterTypeAnnotation($nestedBuilderObject, 'setStreet')); + $this->assertSame('int|null', $this->getParameterTypeAnnotation($nestedBuilderObject, 'setNumber')); + $this->assertSame('string|null', $this->getReturnTypeAnnotation($nestedBuilderObject, 'getStreet')); + $this->assertSame('int|null', $this->getReturnTypeAnnotation($nestedBuilderObject, 'getNumber')); + + $nestedBuilderObject->setStreet('Test street')->setNumber(10); + $this->assertSame($addressArray, $nestedBuilderObject->getRawModelDataInput()); + $builderObject->setAddress($nestedBuilderObject); + $this->assertSame($nestedBuilderObject, $builderObject->getAddress()); + $object = $builderObject->validate(); + $this->assertSame('Test street', $object->getAddress()->getStreet()); + $this->assertSame(10, $object->getAddress()->getNumber()); + + // test add validated object + $nestedObjectClassName = 'MyApp\Namespace\Dependencies\Address'; + $nestedObject = new $nestedObjectClassName($addressArray); + $builderObject->setAddress($nestedObject); + $this->assertSame($nestedObject, $builderObject->getAddress()); + $object = $builderObject->validate(); + $this->assertSame('Test street', $object->getAddress()->getStreet()); + $this->assertSame(10, $object->getAddress()->getNumber()); + + // check if the nested objects from a different namespace are correctly imported + $mainFileContent = file_get_contents(str_replace('.php', 'Builder.php', $files[1])); + $this->assertStringContainsString("use $nestedObjectClassName;", $mainFileContent); + $this->assertStringContainsString("use $nestedBuilderClassName;", $mainFileContent); + } + + public function testNestedObjectArray(): void + { + $className = $this->generateClassFromFile('NestedObjectArray.json'); + + $this->assertGeneratedBuilders(2); + + $builderClassName = $className . 'Builder'; + $builderObject = new $builderClassName(); + + $nestedObjectClassName = null; + foreach ($this->getGeneratedFiles() as $file) { + if (str_contains($file, 'Itemofarray')) { + $nestedObjectClassName = str_replace('.php', '', basename($file)); + + break; + } + } + + $nestedBuilderClassName = $nestedObjectClassName . 'Builder'; + + $this->assertNotEmpty($nestedObjectClassName); + $expectedTypeHint = "{$nestedObjectClassName}[]|{$nestedBuilderClassName}[]|array|null"; + $this->assertSame($expectedTypeHint, $this->getParameterTypeAnnotation($builderObject, 'setAddressList')); + $this->assertSame($expectedTypeHint, $this->getReturnTypeAnnotation($builderObject, 'getAddressList')); + + $builderObject->setAddressList([ + ['street' => 'Test street 0', 'number' => 10], + (new $nestedBuilderClassName())->setStreet('Test street 1')->setNumber(11), + new $nestedObjectClassName(['street' => 'Test street 2', 'number' => 12]), + ]); + + $object = $builderObject->validate(); + + $this->assertCount(3, $object->getAddressList()); + + for ($i = 0; $i <= 2; $i++) { + $this->assertInstanceOf($nestedObjectClassName, $object->getAddressList()[$i]); + $this->assertSame("Test street {$i}", $object->getAddressList()[$i]->getStreet()); + $this->assertSame(10 + $i, $object->getAddressList()[$i]->getNumber()); + } + } + + private function assertGeneratedBuilders(int $expectedGeneratedBuilders): void + { + $dir = sys_get_temp_dir() . '/PHPModelGeneratorTest/Models'; + + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) + ); + + $files = []; + /** @var SplFileInfo $file */ + foreach ($it as $file) { + if ($file->isFile() && str_ends_with($file->getFilename(), 'Builder.php')) { + $files[] = $file->getPathname(); + } + } + + $this->assertCount($expectedGeneratedBuilders, $files); + } +} diff --git a/tests/PostProcessor/EnumPostProcessorTest.php b/tests/PostProcessor/EnumPostProcessorTest.php index bef563b6..0347398e 100644 --- a/tests/PostProcessor/EnumPostProcessorTest.php +++ b/tests/PostProcessor/EnumPostProcessorTest.php @@ -12,6 +12,7 @@ use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\ModelGenerator; +use PHPModelGenerator\SchemaProcessor\PostProcessor\BuilderClassPostProcessor; use PHPModelGenerator\SchemaProcessor\PostProcessor\EnumPostProcessor; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use ReflectionEnum; @@ -47,7 +48,7 @@ public function testStringOnlyEnum(): void false, ); - $this->includeGeneratedEnums(1); + $this->assertGeneratedEnums(1); $object = new $className(['property' => 'hans', 'stringProperty' => 'abc']); $this->assertSame('hans', $object->getProperty()->value); @@ -105,7 +106,6 @@ public function testInvalidStringOnlyEnumValueThrowsAnException(): void { $this->addPostProcessor(); $className = $this->generateClassFromFileTemplate('EnumProperty.json', ['["Hans", "Dieter"]'], null, false); - $this->includeGeneratedEnums(1); $this->expectException(EnumException::class); $this->expectExceptionMessage('Invalid value for property declined by enum constraint'); @@ -147,8 +147,6 @@ public function testMappedStringOnlyEnum(): void false, ); - $this->includeGeneratedEnums(1); - $object = new $className(['property' => 'Hans']); $this->assertSame('Hans', $object->getProperty()->value); $this->assertSame('Ceo', $object->getProperty()->name); @@ -220,7 +218,7 @@ public function testUnmappedEnumIsSkippedWithEnabledSkipOption(): void $className = $this->generateClassFromFileTemplate('EnumProperty.json', ['[0, 1, 2]'], null, false); - $this->includeGeneratedEnums(0); + $this->assertGeneratedEnums(0); $object = new $className(['property' => 1]); $this->assertSame(1, $object->getProperty()); @@ -271,8 +269,6 @@ public function testIntOnlyEnum(): void false, ); - $this->includeGeneratedEnums(1); - $object = new $className(['property' => 10]); $this->assertSame(10, $object->getProperty()->value); $this->assertSame(['property' => 10], $object->toArray()); @@ -335,8 +331,6 @@ public function testMixedEnum(): void false, ); - $this->includeGeneratedEnums(1); - $object = new $className(['property' => 'Hans']); $this->assertSame('Hans', $object->getProperty()->value()); $this->assertSame(['property' => 'Hans'], $object->toArray()); @@ -410,7 +404,7 @@ public function testIdenticalEnumsAreMappedToOneEnum(string $file, array $enums) false, ); - $this->includeGeneratedEnums(1); + $this->assertGeneratedEnums(1); $object = new $className(['property1' => 'Hans', 'property2' => 'Dieter']); $this->assertSame('Hans', $object->getProperty1()->value); @@ -451,7 +445,7 @@ public function testDifferentEnumsAreNotMappedToOneEnum(string $file, array $enu false, ); - $this->includeGeneratedEnums(2); + $this->assertGeneratedEnums(2); $object = new $className(['property1' => 'Hans', 'property2' => 'Dieter']); $this->assertSame('Hans', $object->getProperty1()->value); @@ -470,22 +464,22 @@ public function differentEnumsDataProvider(): array 'different $id' => [ 'MultipleEnumPropertiesMapped.json', [ - '"names"', '["Hans", "Dieter"]', '{"a": "Hans", "b": "Dieter"}', + '"owners"', '["Hans", "Dieter"]', '{"a": "Hans", "b": "Dieter"}', '"attendees"', '["Hans", "Dieter"]', '{"a": "Hans", "b": "Dieter"}', ], ], 'different values mapped enum' => [ 'MultipleEnumPropertiesMapped.json', [ - '"names"', '["Hans", "Anna"]', '{"a": "Hans", "b": "Anna"}', - '"names"', '["Hans", "Dieter"]', '{"a": "Hans", "b": "Dieter"}', + '"visitors"', '["Hans", "Anna"]', '{"a": "Hans", "b": "Anna"}', + '"visitors"', '["Hans", "Dieter"]', '{"a": "Hans", "b": "Dieter"}', ], ], 'different mapping' => [ 'MultipleEnumPropertiesMapped.json', [ - '"names"', '["Hans", "Dieter"]', '{"a": "Hans", "b": "Dieter"}', - '"names"', '["Hans", "Dieter"]', '{"a": "Dieter", "b": "Hans"}', + '"members"', '["Hans", "Dieter"]', '{"a": "Hans", "b": "Dieter"}', + '"members"', '["Hans", "Dieter"]', '{"a": "Dieter", "b": "Hans"}', ], ], ]; @@ -500,8 +494,6 @@ public function testDefaultValue(): void $className = $this->generateClassFromFile('EnumPropertyDefaultValue.json'); - $this->includeGeneratedEnums(1); - $object = new $className(); $this->assertSame('Dieter', $object->getProperty()->value); } @@ -515,8 +507,6 @@ public function testNotProvidedRequiredEnumThrowsAnException(): void $className = $this->generateClassFromFile('EnumPropertyRequired.json'); - $this->includeGeneratedEnums(1); - $this->expectException(RequiredValueException::class); $this->expectExceptionMessage('Missing required value for property'); @@ -535,8 +525,6 @@ public function testRequiredEnum(): void (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), ); - $this->includeGeneratedEnums(1); - $object = new $className(['property' => 'Dieter']); $this->assertSame('Dieter', $object->getProperty()->value); @@ -587,8 +575,6 @@ public function testNameNormalization(string $name, string $expectedNormalizedNa $className = $this->generateClassFromFileTemplate('EnumProperty.json', [sprintf('["%s"]', $name)], null, false); - $this->includeGeneratedEnums(1); - $object = new $className(); $returnType = $this->getReturnType($object, 'getProperty'); @@ -600,6 +586,64 @@ public function testNameNormalization(string $name, string $expectedNormalizedNa ); } + /** + * @requires PHP >= 8.1 + */ + public function testEnumForBuilderClass(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator + ->addPostProcessor(new BuilderClassPostProcessor()) + ->addPostProcessor( + new EnumPostProcessor( + join(DIRECTORY_SEPARATOR, [sys_get_temp_dir(), 'PHPModelGeneratorTest', 'Enum']), + 'Enum', + ) + ); + }; + + $className = $this->generateClassFromFileTemplate('EnumProperty.json', ['["hans", "dieter"]'], escape: false); + $builderClassName = $className . 'Builder'; + + $this->assertGeneratedEnums(1); + + $builder = new $builderClassName(); + $builder->setProperty('dieter'); + + $object = $builder->validate(); + $this->assertInstanceOf($className, $object); + $this->assertSame('dieter', $object->getProperty()->value); + + $returnType = $this->getReturnType($object, 'getProperty'); + $enum = $returnType->getName(); + $this->assertTrue(enum_exists($enum)); + + $reflectionEnum = new ReflectionEnum($enum); + $enumName = $reflectionEnum->getShortName(); + + $this->assertNull($this->getReturnType($builder, 'getProperty')); + $this->assertEqualsCanonicalizing( + [$enumName, 'string', 'null'], + explode('|', $this->getReturnTypeAnnotation($builder, 'getProperty')), + ); + + $this->assertNull($this->getParameterType($builder, 'setProperty')); + $this->assertEqualsCanonicalizing( + [$enumName, 'string', 'null'], + explode('|', $this->getParameterTypeAnnotation($builder, 'setProperty')), + ); + + $builder->setProperty($enum::Hans); + $object = $builder->validate(); + $this->assertInstanceOf($className, $object); + $this->assertSame('hans', $object->getProperty()->value); + + $builder->setProperty('Meier'); + $this->expectException(EnumException::class); + $this->expectExceptionMessage('Invalid value for property declined by enum constraint'); + $builder->validate(); + } + public function normalizedNamesDataProvider(): array { return [ @@ -621,15 +665,11 @@ private function addPostProcessor(): void }; } - private function includeGeneratedEnums(int $expectedGeneratedEnums): void + private function assertGeneratedEnums(int $expectedGeneratedEnums): void { $dir = sys_get_temp_dir() . '/PHPModelGeneratorTest/Enum'; $files = array_diff(scandir($dir), ['.', '..']); $this->assertCount($expectedGeneratedEnums, $files); - - foreach ($files as $file) { - require_once $dir . DIRECTORY_SEPARATOR . $file; - } } } diff --git a/tests/PropertyProcessor/PropertyProcessorFactoryTest.php b/tests/PropertyProcessor/PropertyProcessorFactoryTest.php index bf1e7d1d..f60814e7 100644 --- a/tests/PropertyProcessor/PropertyProcessorFactoryTest.php +++ b/tests/PropertyProcessor/PropertyProcessorFactoryTest.php @@ -41,7 +41,7 @@ public function testGetPropertyProcessor(string $type, string $expectedClass): v $type, new PropertyMetaDataCollection(), new SchemaProcessor('', '', new GeneratorConfiguration(), new RenderQueue()), - new Schema('', '', new JsonSchema('', [])), + new Schema('', '', '', new JsonSchema('', [])), ); $this->assertInstanceOf($expectedClass, $propertyProcessor); @@ -77,7 +77,7 @@ public function testGetInvalidPropertyProcessorThrowsAnException(): void 'Hello', new PropertyMetaDataCollection(), new SchemaProcessor('', '', new GeneratorConfiguration(), new RenderQueue()), - new Schema('', '', new JsonSchema('', [])), + new Schema('', '', '', new JsonSchema('', [])), ); } } diff --git a/tests/Schema/BasicSchemaGenerationTest/ReadOnly.json b/tests/Schema/BasicSchemaGenerationTest/ReadOnly.json index 9aeb39cd..3168416a 100644 --- a/tests/Schema/BasicSchemaGenerationTest/ReadOnly.json +++ b/tests/Schema/BasicSchemaGenerationTest/ReadOnly.json @@ -3,11 +3,11 @@ "properties": { "readOnlyTrue": { "type": "string", - "readOnly": true + "readonly": true }, "readOnlyFalse": { "type": "string", - "readOnly": false + "readonly": false }, "noReadOnly": { "type": "string" diff --git a/tests/Schema/BuilderClassPostProcessorTest/BasicSchema.json b/tests/Schema/BuilderClassPostProcessorTest/BasicSchema.json new file mode 100644 index 00000000..314500f8 --- /dev/null +++ b/tests/Schema/BuilderClassPostProcessorTest/BasicSchema.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 5 + }, + "age": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "name" + ] +} \ No newline at end of file diff --git a/tests/Schema/BuilderClassPostProcessorTest/NestedObject/Dependencies/Address.json b/tests/Schema/BuilderClassPostProcessorTest/NestedObject/Dependencies/Address.json new file mode 100644 index 00000000..86d1e929 --- /dev/null +++ b/tests/Schema/BuilderClassPostProcessorTest/NestedObject/Dependencies/Address.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "number": { + "type": "integer" + } + } +} \ No newline at end of file diff --git a/tests/Schema/BuilderClassPostProcessorTest/NestedObject/NestedObject.json b/tests/Schema/BuilderClassPostProcessorTest/NestedObject/NestedObject.json new file mode 100644 index 00000000..a453a7f5 --- /dev/null +++ b/tests/Schema/BuilderClassPostProcessorTest/NestedObject/NestedObject.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "address": { + "$ref": "./Dependencies/Address.json" + } + } +} \ No newline at end of file diff --git a/tests/Schema/BuilderClassPostProcessorTest/NestedObjectArray.json b/tests/Schema/BuilderClassPostProcessorTest/NestedObjectArray.json new file mode 100644 index 00000000..30c3f37b --- /dev/null +++ b/tests/Schema/BuilderClassPostProcessorTest/NestedObjectArray.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "addressList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "number": { + "type": "integer" + } + } + } + } + } +} \ No newline at end of file