From d9984ca87c8629416a9841738a2184db7f541d2b Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 11 Sep 2025 18:36:15 +0200 Subject: [PATCH 01/16] BuilderClassPostProcessor draft --- src/Model/RenderJob.php | 67 ++++----- src/Model/Schema.php | 6 + .../SchemaDefinitionDictionary.php | 1 + .../Property/AbstractValueProcessor.php | 5 + .../BuilderClassPostProcessor.php | 135 ++++++++++++++++++ .../PostProcessor/EnumPostProcessor.php | 9 +- .../Templates/BuilderClass.phptpl | 103 +++++++++++++ src/SchemaProcessor/SchemaProcessor.php | 52 ++++--- src/Templates/Model.phptpl | 10 +- src/Utils/RenderHelper.php | 9 ++ .../PropertyProcessorFactoryTest.php | 4 +- .../BasicSchemaGenerationTest/ReadOnly.json | 4 +- 12 files changed, 346 insertions(+), 59 deletions(-) create mode 100644 src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php create mode 100644 src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl diff --git a/src/Model/RenderJob.php b/src/Model/RenderJob.php index 1cc9ca48..0f221f26 100644 --- a/src/Model/RenderJob.php +++ b/src/Model/RenderJob.php @@ -24,15 +24,9 @@ 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, ) {} @@ -58,13 +52,17 @@ 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 } @@ -73,8 +71,12 @@ public function render(GeneratorConfiguration $generatorConfiguration): void "Rendered class %s\n", join( '\\', - array_filter([$generatorConfiguration->getNamespacePrefix(), $this->classPath, $this->className]), - ) + array_filter([ + $generatorConfiguration->getNamespacePrefix(), + $this->schema->getClassPath(), + $this->schema->getClassName(), + ]), + ), ); } } @@ -86,7 +88,7 @@ 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)) { throw new FileSystemException("Can't create path $destination"); } @@ -99,7 +101,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 +112,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 +125,11 @@ protected function renderClass(GeneratorConfiguration $generatorConfiguration): ], ); } catch (PHPMicroTemplateException $exception) { - throw new RenderException("Can't render class $this->classPath\\$this->className", 0, $exception); + throw new RenderException( + "Can't render class {$this->schema->getClassPath()}\\{$this->schema->getClassName()}", + 0, + $exception, + ); } return $class; @@ -132,21 +140,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/Property/AbstractValueProcessor.php b/src/PropertyProcessor/Property/AbstractValueProcessor.php index d47c0014..0a6a8763 100644 --- a/src/PropertyProcessor/Property/AbstractValueProcessor.php +++ b/src/PropertyProcessor/Property/AbstractValueProcessor.php @@ -53,9 +53,14 @@ 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'])) { + trigger_error('Change from readOnly to readonly for property "' . $propertyName . '".', E_USER_DEPRECATED); + } + $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..449df7cb --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php @@ -0,0 +1,135 @@ +schemas[] = $schema; + $this->generatorConfiguration = $generatorConfiguration; + } + + public function postProcess(): void + { + parent::postProcess(); + + // TODO: implicit null? + // TODO: nested objects + + foreach ($this->schemas as $schema) { + $properties = []; + foreach ($schema->getProperties() as $property) { + if (!$property->isInternal()) { + $properties[] = (clone $property) + ->setReadOnly(false) + ->setType($property->getType(), new PropertyType($property->getType(true)->getName(), true)) + ->addTypeHintDecorator(new TypeHintTransferDecorator($property)) + ->filterValidators(static fn(Validator $validator): bool + => is_a($validator->getValidator(), FilterValidator::class) + ); + } + } + + $this->generateModelDirectory($schema->getTargetFileName()); + + $namespace = trim( + join('\\', [$this->generatorConfiguration->getNamespacePrefix(), $schema->getClassPath()]), + '\\', + ); + + $result = file_put_contents( + 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 = "{$schema->getClassPath()}\\{$schema->getClassName()}Builder"; + + if ($result === false) { + // @codeCoverageIgnoreStart + throw new FileSystemException("Can't write builder class $fqcn.",); + // @codeCoverageIgnoreEnd + } + + if ($this->generatorConfiguration->isOutputEnabled()) { + // @codeCoverageIgnoreStart + echo "Rendered builder class $fqcn\n"; + // @codeCoverageIgnoreEnd + } + } + } + + protected function generateModelDirectory(string $targetFileName): void + { + $destination = dirname($targetFileName); + if (!is_dir($destination) && !mkdir($destination, 0777, true)) { + throw new FileSystemException("Can't create path $destination"); + } + } + + /** + * @param PropertyInterface[] $properties + * + * @return string[] + */ + private function getBuilderClassImports(array $properties, array $originalClassImports, string $namespace): array + { + $imports = []; + + if ($this->generatorConfiguration->collectErrors()) { + $imports[] = $this->generatorConfiguration->getErrorRegistryClass(); + } + + foreach ($properties as $property) { + foreach (array_unique( + [...explode('|', $property->getTypeHint()), ...explode('|', $property->getTypeHint(true))] + ) as $type) { + // 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)) { + $imports[] = $type; + } + } + } + + return RenderHelper::filterClassImports(array_unique($imports), $namespace); + } +} diff --git a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php index e3915c8c..89e75ff3 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,7 +256,7 @@ private function renderEnum( $name .= '_1'; } - file_put_contents( + $result = file_put_contents( $this->targetDirectory . DIRECTORY_SEPARATOR . $name . '.php', $this->renderer->renderTemplate( 'Enum.phptpl', @@ -270,6 +271,12 @@ private function renderEnum( $fqcn = "$this->namespace\\$name"; + if ($result === false) { + // @codeCoverageIgnoreStart + throw new FileSystemException("Can't write enum $fqcn."); + // @codeCoverageIgnoreEnd + } + if ($generatorConfiguration->isOutputEnabled()) { // @codeCoverageIgnoreStart echo "Rendered enum $fqcn\n"; diff --git a/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl b/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl new file mode 100644 index 00000000..ec9ad0b9 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl @@ -0,0 +1,103 @@ +_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 }} + { + 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, true) %}@return {{ viewHelper.getTypeHintAnnotation(property, true) }}{% endif %} + */ + public function get{{ viewHelper.ucfirst(property.getAttribute()) }}() + {% if property.getType(true) %}: {{ viewHelper.getType(property, 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 %} + * + * @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 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/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..045bc60a 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; } diff --git a/src/Utils/RenderHelper.php b/src/Utils/RenderHelper.php index 86022c34..5545ac0c 100644 --- a/src/Utils/RenderHelper.php +++ b/src/Utils/RenderHelper.php @@ -161,4 +161,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/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" From c7b0685c121c69b9066cb3650691b8a22004d3d4 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 11 Sep 2025 18:47:54 +0200 Subject: [PATCH 02/16] BuilderClassPostProcessor draft --- .github/workflows/main.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bc408753..44a709be 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -32,15 +32,12 @@ jobs: run: | ./vendor/bin/phpunit --testdox - - name: Upload the reports to codeclimate + - name: Upload coverage to Qlty if: ${{ matrix.coverage }} - env: - XDEBUG_MODE: coverage - CC_TEST_REPORTER_ID: 5e32818628fac9eb11d34e2b35289f88169610cc4a98c6f170c74923342284f1 - uses: paambaati/codeclimate-action@v9 + 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 }} From db273ada8da05fde95c03ff47516ac110c70f19d Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 11 Sep 2025 18:52:56 +0200 Subject: [PATCH 03/16] BuilderClassPostProcessor draft --- .github/workflows/main.yml | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 44a709be..162b058a 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'] @@ -27,10 +31,17 @@ jobs: - 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 + + # Run tests WITH Clover coverage for the coverage job + - name: Execute tests with coverage (Clover) + if: ${{ matrix.coverage }} + env: + XDEBUG_MODE: coverage + run: ./vendor/bin/phpunit --coverage-clover=build/logs/clover.xml --testdox - name: Upload coverage to Qlty if: ${{ matrix.coverage }} @@ -41,10 +52,10 @@ jobs: - 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 From 8d53dd7ec4e09f0e0af0e67267ca2e0c927e1793 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 15 Sep 2025 17:21:45 +0200 Subject: [PATCH 04/16] BuilderClassPostProcessor draft --- .codeclimate.yml | 18 ----- README.md | 2 +- composer.json | 2 +- src/Model/Property/Property.php | 1 + .../BuilderClassPostProcessor.php | 28 +++++-- .../GetAdditionalProperties.phptpl | 2 +- .../GetAdditionalProperty.phptpl | 4 +- .../Templates/BuilderClass.phptpl | 15 +++- .../PostProcessor/Templates/Populate.phptpl | 10 +-- src/Templates/Model.phptpl | 10 +-- src/Templates/Validator/ComposedItem.phptpl | 12 +-- .../BuilderClassPostProcessorTest.php | 78 +++++++++++++++++++ .../BasicSchema.json | 14 ++++ 13 files changed, 145 insertions(+), 51 deletions(-) delete mode 100644 .codeclimate.yml create mode 100644 tests/PostProcessor/BuilderClassPostProcessorTest.php create mode 100644 tests/Schema/BuilderClassPostProcessorTest/BasicSchema.json 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/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..42f3eb3d 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "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-micro-template": "^1.10.0", "php": ">=8.0", "ext-json": "*", 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/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php index 449df7cb..0aa56de3 100644 --- a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php @@ -6,12 +6,16 @@ use PHPMicroTemplate\Render; use PHPModelGenerator\Exception\FileSystemException; +use PHPModelGenerator\Exception\ValidationException; +use PHPModelGenerator\Interfaces\BuilderInterface; +use PHPModelGenerator\Interfaces\JSONModelInterface; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Property\PropertyType; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Validator\FilterValidator; +use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintTransferDecorator; use PHPModelGenerator\Utils\RenderHelper; @@ -28,6 +32,8 @@ class BuilderClassPostProcessor extends PostProcessor public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void { + // collect the schemas and generate builder classes in postProcess hook to make sure the related model class + // already has been created $this->schemas[] = $schema; $this->generatorConfiguration = $generatorConfiguration; } @@ -36,17 +42,16 @@ public function postProcess(): void { parent::postProcess(); - // TODO: implicit null? - // TODO: nested objects - foreach ($this->schemas as $schema) { $properties = []; foreach ($schema->getProperties() as $property) { if (!$property->isInternal()) { $properties[] = (clone $property) ->setReadOnly(false) + // ensure the getter methods for required properties can return null (they have not been set yet) ->setType($property->getType(), new PropertyType($property->getType(true)->getName(), true)) ->addTypeHintDecorator(new TypeHintTransferDecorator($property)) + // keep filters to ensure values set on the builder match the return type of the getter ->filterValidators(static fn(Validator $validator): bool => is_a($validator->getValidator(), FilterValidator::class) ); @@ -107,13 +112,13 @@ protected function generateModelDirectory(string $targetFileName): void */ private function getBuilderClassImports(array $properties, array $originalClassImports, string $namespace): array { - $imports = []; - - if ($this->generatorConfiguration->collectErrors()) { - $imports[] = $this->generatorConfiguration->getErrorRegistryClass(); - } + $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('|', $property->getTypeHint()), ...explode('|', $property->getTypeHint(true))] ) as $type) { @@ -126,6 +131,13 @@ private function getBuilderClassImports(array $properties, array $originalClassI if (class_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))) { + $property->addTypeHintDecorator(new TypeHintDecorator(['array', basename($type) . 'Builder'])); + $property->setType(); + } } } } 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 index ec9ad0b9..9e091e23 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl @@ -16,7 +16,7 @@ declare(strict_types = 1); /** * Builder class for {{ class }} */ -class {{ class }}Builder +class {{ class }}Builder implements BuilderInterface { /** @var array */ protected $_rawModelDataInput = []; @@ -46,6 +46,15 @@ class {{ class }}Builder */ public function validate(): {{ class }} { + array_walk_recursive( + $this->_rawModelDataInput, + function (&$property): void { + if ($property instanceof BuilderInterface) { + $property = $property->getRawModelDataInput(); + } + }, + ); + return new {{ class }}($this->_rawModelDataInput); } @@ -69,6 +78,8 @@ class {{ class }}Builder * * @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()) }}( @@ -86,7 +97,7 @@ class {{ class }}Builder {{ viewHelper.renderValidator(validator, schema) }} {% endforeach %} - {% if generatorConfiguration.collectErrors() %} + {% if property.getOrderedValidators() and generatorConfiguration.collectErrors() %} if ($this->_errorRegistry->getErrors()) { throw $this->_errorRegistry; } 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/Templates/Model.phptpl b/src/Templates/Model.phptpl index 045bc60a..e39354c0 100644 --- a/src/Templates/Model.phptpl +++ b/src/Templates/Model.phptpl @@ -168,12 +168,10 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v */ 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/tests/PostProcessor/BuilderClassPostProcessorTest.php b/tests/PostProcessor/BuilderClassPostProcessorTest.php new file mode 100644 index 00000000..3fdd91af --- /dev/null +++ b/tests/PostProcessor/BuilderClassPostProcessorTest.php @@ -0,0 +1,78 @@ +modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new BuilderClassPostProcessor()); + }; + } + + public function testPopulateMethod(): void + { + $className = $this->generateClassFromFile( + 'BasicSchema.json', + (new GeneratorConfiguration())->setSerialization(true), + ); + + $this->includeGeneratedBuilder(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|null', $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()); + } + + private function includeGeneratedBuilder(int $expectedGeneratedBuilders): void + { + $dir = sys_get_temp_dir() . '/PHPModelGeneratorTest/Models'; + $files = array_filter(scandir($dir), fn (string $file): bool => str_ends_with($file, 'Builder.php')); + + $this->assertCount($expectedGeneratedBuilders, $files); + + foreach ($files as $file) { + require_once $dir . DIRECTORY_SEPARATOR . $file; + } + } +} diff --git a/tests/Schema/BuilderClassPostProcessorTest/BasicSchema.json b/tests/Schema/BuilderClassPostProcessorTest/BasicSchema.json new file mode 100644 index 00000000..cd79b18b --- /dev/null +++ b/tests/Schema/BuilderClassPostProcessorTest/BasicSchema.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "integer" + } + }, + "required": [ + "name" + ] +} \ No newline at end of file From 36ca8c97cc73258afedd4b0330c7c94e33f55258 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 15 Sep 2025 17:25:32 +0200 Subject: [PATCH 05/16] BuilderClassPostProcessor draft --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 42f3eb3d..6cfacf27 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ ], "require": { "symfony/polyfill-php81": "^1.28", - "wol-soft/php-json-schema-model-generator-production": "^0.19.0", + "wol-soft/php-json-schema-model-generator-production": "dev-BuilderClassPostProcessor", "wol-soft/php-micro-template": "^1.10.0", "php": ">=8.0", From 1d339fe4936e3a9698abbbcf8585fc1f16ab9591 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 16 Sep 2025 16:21:04 +0200 Subject: [PATCH 06/16] remove unnecessary function --- .../PostProcessor/BuilderClassPostProcessor.php | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php index 0aa56de3..b048a540 100644 --- a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php @@ -19,11 +19,6 @@ use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintTransferDecorator; use PHPModelGenerator\Utils\RenderHelper; -/** - * Class PopulatePostProcessor - * - * @package PHPModelGenerator\SchemaProcessor\PostProcessor - */ class BuilderClassPostProcessor extends PostProcessor { /** @var Schema[] */ @@ -58,8 +53,6 @@ public function postProcess(): void } } - $this->generateModelDirectory($schema->getTargetFileName()); - $namespace = trim( join('\\', [$this->generatorConfiguration->getNamespacePrefix(), $schema->getClassPath()]), '\\', @@ -97,14 +90,6 @@ public function postProcess(): void } } - protected function generateModelDirectory(string $targetFileName): void - { - $destination = dirname($targetFileName); - if (!is_dir($destination) && !mkdir($destination, 0777, true)) { - throw new FileSystemException("Can't create path $destination"); - } - } - /** * @param PropertyInterface[] $properties * From fbb50e5b296e6e75bfd8e9b74fa54194fff0a32b Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Wed, 17 Sep 2025 20:05:03 +0200 Subject: [PATCH 07/16] Draft --- src/Model/RenderJob.php | 4 +- .../BuilderClassPostProcessor.php | 14 +++- .../PostProcessor/EnumPostProcessor.php | 4 +- src/SchemaProcessor/RenderQueue.php | 2 +- tests/AbstractPHPModelGeneratorTestCase.php | 4 - .../BuilderClassPostProcessorTest.php | 83 +++++++++++++++++-- tests/PostProcessor/EnumPostProcessorTest.php | 29 ++----- .../NestedObject.json | 16 ++++ 8 files changed, 114 insertions(+), 42 deletions(-) create mode 100644 tests/Schema/BuilderClassPostProcessorTest/NestedObject.json diff --git a/src/Model/RenderJob.php b/src/Model/RenderJob.php index 0f221f26..41416a19 100644 --- a/src/Model/RenderJob.php +++ b/src/Model/RenderJob.php @@ -33,7 +33,7 @@ public function __construct( /** * @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); @@ -66,6 +66,8 @@ public function render(GeneratorConfiguration $generatorConfiguration): void // @codeCoverageIgnoreEnd } + require $this->schema->getTargetFileName(); + if ($generatorConfiguration->isOutputEnabled()) { echo sprintf( "Rendered class %s\n", diff --git a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php index b048a540..d9ad9eb1 100644 --- a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php @@ -18,6 +18,7 @@ use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintTransferDecorator; use PHPModelGenerator\Utils\RenderHelper; +use UnitEnum; class BuilderClassPostProcessor extends PostProcessor { @@ -59,7 +60,7 @@ public function postProcess(): void ); $result = file_put_contents( - str_replace('.php', 'Builder.php', $schema->getTargetFileName()), + $filename = str_replace('.php', 'Builder.php', $schema->getTargetFileName()), (new Render(__DIR__ . DIRECTORY_SEPARATOR . 'Templates' . DIRECTORY_SEPARATOR))->renderTemplate( 'BuilderClass.phptpl', [ @@ -74,7 +75,7 @@ public function postProcess(): void ) ); - $fqcn = "{$schema->getClassPath()}\\{$schema->getClassName()}Builder"; + $fqcn = "$namespace\\{$schema->getClassName()}Builder"; if ($result === false) { // @codeCoverageIgnoreStart @@ -82,6 +83,8 @@ public function postProcess(): void // @codeCoverageIgnoreEnd } + require $filename; + if ($this->generatorConfiguration->isOutputEnabled()) { // @codeCoverageIgnoreStart echo "Rendered builder class $fqcn\n"; @@ -114,13 +117,18 @@ private function getBuilderClassImports(array $properties, array $originalClassI } } + // required for compatibility with the EnumPostProcessor + if (enum_exists($type)) { + array_push($imports, $type, UnitEnum::class); + } + if (class_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))) { - $property->addTypeHintDecorator(new TypeHintDecorator(['array', basename($type) . 'Builder'])); + $property->addTypeHintDecorator(new TypeHintDecorator([basename($type) . 'Builder', 'array'])); $property->setType(); } } diff --git a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php index 89e75ff3..80b618df 100644 --- a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php @@ -257,7 +257,7 @@ private function renderEnum( } $result = file_put_contents( - $this->targetDirectory . DIRECTORY_SEPARATOR . $name . '.php', + $filename = $this->targetDirectory . DIRECTORY_SEPARATOR . $name . '.php', $this->renderer->renderTemplate( 'Enum.phptpl', [ @@ -277,6 +277,8 @@ private function renderEnum( // @codeCoverageIgnoreEnd } + require $filename; + if ($generatorConfiguration->isOutputEnabled()) { // @codeCoverageIgnoreStart echo "Rendered enum $fqcn\n"; 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/tests/AbstractPHPModelGeneratorTestCase.php b/tests/AbstractPHPModelGeneratorTestCase.php index 78d4ada9..b77bfef1 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; @@ -283,8 +281,6 @@ protected function generateDirectory(string $directory, GeneratorConfiguration $ foreach ($generatedClasses as $path) { $this->generatedFiles[] = $path; - - require $path; } return $generatedClasses; diff --git a/tests/PostProcessor/BuilderClassPostProcessorTest.php b/tests/PostProcessor/BuilderClassPostProcessorTest.php index 3fdd91af..efb1932b 100644 --- a/tests/PostProcessor/BuilderClassPostProcessorTest.php +++ b/tests/PostProcessor/BuilderClassPostProcessorTest.php @@ -20,14 +20,15 @@ public function setUp(): void }; } - public function testPopulateMethod(): void + public function testBuilder(): void { $className = $this->generateClassFromFile( 'BasicSchema.json', (new GeneratorConfiguration())->setSerialization(true), + implicitNull: false, ); - $this->includeGeneratedBuilder(1); + $this->assertGeneratedBuilders(1); $builderClassName = $className . 'Builder'; $builderObject = new $builderClassName(); @@ -43,7 +44,7 @@ public function testPopulateMethod(): void $this->assertEqualsCanonicalizing(['name' => 'Albert', 'age' => 65], $builderObject->getRawModelDataInput()); $this->assertSame('string', $this->getParameterTypeAnnotation($builderObject, 'setName')); - $this->assertSame('int|null', $this->getParameterTypeAnnotation($builderObject, 'setAge')); + $this->assertSame('int', $this->getParameterTypeAnnotation($builderObject, 'setAge')); $this->assertSame('string|null', $this->getReturnTypeAnnotation($builderObject, 'getName')); $this->assertSame('int|null', $this->getReturnTypeAnnotation($builderObject, 'getAge')); @@ -64,15 +65,81 @@ public function testPopulateMethod(): void $this->assertEqualsCanonicalizing(['name' => 'Albert', 'age' => 65], $validatedObject->toArray()); } - private function includeGeneratedBuilder(int $expectedGeneratedBuilders): void + 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 + { + $className = $this->generateClassFromFile('NestedObject.json'); + + $this->assertGeneratedBuilders(2); + + $builderClassName = $className . 'Builder'; + $builderObject = new $builderClassName(); + + $nestedObjectClassName = null; + foreach ($this->getGeneratedFiles() as $file) { + if (str_contains($file, 'Address')) { + $nestedObjectClassName = str_replace('.php', '', basename($file)); + + break; + } + } + + $this->assertNotEmpty($nestedObjectClassName); + $expectedTypeHint = "$nestedObjectClassName|{$nestedObjectClassName}Builder|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 = $nestedObjectClassName . 'Builder'; + $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 + $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()); + } + + private function assertGeneratedBuilders(int $expectedGeneratedBuilders): void { $dir = sys_get_temp_dir() . '/PHPModelGeneratorTest/Models'; $files = array_filter(scandir($dir), fn (string $file): bool => str_ends_with($file, 'Builder.php')); $this->assertCount($expectedGeneratedBuilders, $files); - - foreach ($files as $file) { - require_once $dir . DIRECTORY_SEPARATOR . $file; - } } } diff --git a/tests/PostProcessor/EnumPostProcessorTest.php b/tests/PostProcessor/EnumPostProcessorTest.php index bef563b6..39c01766 100644 --- a/tests/PostProcessor/EnumPostProcessorTest.php +++ b/tests/PostProcessor/EnumPostProcessorTest.php @@ -47,7 +47,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 +105,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 +146,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 +217,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 +268,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 +330,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 +403,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 +444,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); @@ -500,8 +493,6 @@ public function testDefaultValue(): void $className = $this->generateClassFromFile('EnumPropertyDefaultValue.json'); - $this->includeGeneratedEnums(1); - $object = new $className(); $this->assertSame('Dieter', $object->getProperty()->value); } @@ -515,8 +506,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 +524,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 +574,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'); @@ -621,15 +606,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/Schema/BuilderClassPostProcessorTest/NestedObject.json b/tests/Schema/BuilderClassPostProcessorTest/NestedObject.json new file mode 100644 index 00000000..6ea10d23 --- /dev/null +++ b/tests/Schema/BuilderClassPostProcessorTest/NestedObject.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "address": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "number": { + "type": "integer" + } + } + } + } +} \ No newline at end of file From 59383d515e9d6e5c6b61714ce33abb0c2e95b59a Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 18 Sep 2025 15:40:03 +0200 Subject: [PATCH 08/16] use distinct IDs to avoid overlapping when including the files --- .../Property/AbstractValueProcessor.php | 2 ++ tests/PostProcessor/EnumPostProcessorTest.php | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/PropertyProcessor/Property/AbstractValueProcessor.php b/src/PropertyProcessor/Property/AbstractValueProcessor.php index 0a6a8763..1cee5464 100644 --- a/src/PropertyProcessor/Property/AbstractValueProcessor.php +++ b/src/PropertyProcessor/Property/AbstractValueProcessor.php @@ -58,7 +58,9 @@ public function process(string $propertyName, JsonSchema $propertySchema): Prope ); if (isset($json['readOnly'])) { + // @codeCoverageIgnoreStart trigger_error('Change from readOnly to readonly for property "' . $propertyName . '".', E_USER_DEPRECATED); + // @codeCoverageIgnoreEnd } $this->generateValidators($property, $propertySchema); diff --git a/tests/PostProcessor/EnumPostProcessorTest.php b/tests/PostProcessor/EnumPostProcessorTest.php index 39c01766..aded2b64 100644 --- a/tests/PostProcessor/EnumPostProcessorTest.php +++ b/tests/PostProcessor/EnumPostProcessorTest.php @@ -463,22 +463,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"}', ], ], ]; From 32a7539854c4359cf0471961831534577b1034df Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 19 Sep 2025 11:27:56 +0200 Subject: [PATCH 09/16] Additional test cases --- src/Model/RenderJob.php | 4 ++ .../BuilderClassPostProcessor.php | 26 +++++-- tests/AbstractPHPModelGeneratorTestCase.php | 2 +- .../BuilderClassPostProcessorTest.php | 70 ++++++++++++++++++- .../BasicSchema.json | 6 +- .../NestedObjectArray.json | 19 +++++ 6 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 tests/Schema/BuilderClassPostProcessorTest/NestedObjectArray.json diff --git a/src/Model/RenderJob.php b/src/Model/RenderJob.php index 41416a19..f267a7a3 100644 --- a/src/Model/RenderJob.php +++ b/src/Model/RenderJob.php @@ -92,7 +92,9 @@ protected function generateModelDirectory(): void { $destination = dirname($this->schema->getTargetFileName()); if (!is_dir($destination) && !mkdir($destination, 0777, true)) { + // @codeCoverageIgnoreStart throw new FileSystemException("Can't create path $destination"); + // @codeCoverageIgnoreEnd } } @@ -127,11 +129,13 @@ protected function renderClass(GeneratorConfiguration $generatorConfiguration): ], ); } catch (PHPMicroTemplateException $exception) { + // @codeCoverageIgnoreStart throw new RenderException( "Can't render class {$this->schema->getClassPath()}\\{$this->schema->getClassName()}", 0, $exception, ); + // @codeCoverageIgnoreEnd } return $class; diff --git a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php index d9ad9eb1..06a818fb 100644 --- a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php @@ -24,14 +24,22 @@ class BuilderClassPostProcessor extends PostProcessor { /** @var Schema[] */ private array $schemas = []; - private GeneratorConfiguration $generatorConfiguration; + private ?GeneratorConfiguration $generatorConfiguration; + private ?RenderHelper $renderHelper; public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void { // collect the schemas and generate builder classes in postProcess hook to make sure the related model class // already has been created $this->schemas[] = $schema; - $this->generatorConfiguration = $generatorConfiguration; + $this->generatorConfiguration ??= $generatorConfiguration; + $this->renderHelper ??= new RenderHelper($generatorConfiguration); + } + + public function preProcess(): void + { + $this->generatorConfiguration = null; + $this->renderHelper = null; } public function postProcess(): void @@ -107,9 +115,12 @@ private function getBuilderClassImports(array $properties, array $originalClassI foreach ($properties as $property) { // use typehint instead of type to cover multi-types - foreach (array_unique( - [...explode('|', $property->getTypeHint()), ...explode('|', $property->getTypeHint(true))] - ) as $type) { + 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")) { @@ -128,7 +139,10 @@ private function getBuilderClassImports(array $properties, array $originalClassI // 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))) { - $property->addTypeHintDecorator(new TypeHintDecorator([basename($type) . 'Builder', 'array'])); + $property->addTypeHintDecorator(new TypeHintDecorator( + [basename($type) . 'Builder' . (str_contains($typeAnnotation, '[]') ? '[]' : ''), 'array'], + )); + $property->setType(); } } diff --git a/tests/AbstractPHPModelGeneratorTestCase.php b/tests/AbstractPHPModelGeneratorTestCase.php index b77bfef1..db6e8fd7 100644 --- a/tests/AbstractPHPModelGeneratorTestCase.php +++ b/tests/AbstractPHPModelGeneratorTestCase.php @@ -332,7 +332,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 index efb1932b..ba0509c1 100644 --- a/tests/PostProcessor/BuilderClassPostProcessorTest.php +++ b/tests/PostProcessor/BuilderClassPostProcessorTest.php @@ -65,6 +65,29 @@ public function testBuilder(): void $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'); @@ -96,8 +119,10 @@ public function testNestedObject(): void } } + $nestedBuilderClassName = $nestedObjectClassName . 'Builder'; + $this->assertNotEmpty($nestedObjectClassName); - $expectedTypeHint = "$nestedObjectClassName|{$nestedObjectClassName}Builder|array|null"; + $expectedTypeHint = "$nestedObjectClassName|$nestedBuilderClassName|array|null"; $this->assertSame($expectedTypeHint, $this->getParameterTypeAnnotation($builderObject, 'setAddress')); $this->assertSame($expectedTypeHint, $this->getReturnTypeAnnotation($builderObject, 'getAddress')); @@ -111,7 +136,6 @@ public function testNestedObject(): void $this->assertSame(10, $object->getAddress()->getNumber()); // test generate nested object from nested builder - $nestedBuilderClassName = $nestedObjectClassName . 'Builder'; $nestedBuilderObject = new $nestedBuilderClassName(); $this->assertSame('string|null', $this->getParameterTypeAnnotation($nestedBuilderObject, 'setStreet')); $this->assertSame('int|null', $this->getParameterTypeAnnotation($nestedBuilderObject, 'setNumber')); @@ -135,6 +159,48 @@ public function testNestedObject(): void $this->assertSame(10, $object->getAddress()->getNumber()); } + 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'; diff --git a/tests/Schema/BuilderClassPostProcessorTest/BasicSchema.json b/tests/Schema/BuilderClassPostProcessorTest/BasicSchema.json index cd79b18b..314500f8 100644 --- a/tests/Schema/BuilderClassPostProcessorTest/BasicSchema.json +++ b/tests/Schema/BuilderClassPostProcessorTest/BasicSchema.json @@ -2,10 +2,12 @@ "type": "object", "properties": { "name": { - "type": "string" + "type": "string", + "minLength": 5 }, "age": { - "type": "integer" + "type": "integer", + "minimum": 0 } }, "required": [ 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 From b9935c8f521525f721a0bdea31bb7de8be614472 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 19 Sep 2025 11:52:01 +0200 Subject: [PATCH 10/16] split docs for post processors to multiple pages --- docs/source/complexTypes/enum.rst | 2 +- docs/source/complexTypes/object.rst | 4 +- docs/source/conf.py | 6 +- ...itionalPropertiesAccessorPostProcessor.rst | 62 ++++ .../generator/builtin/enumPostProcessor.rst | 94 +++++ ...patternPropertiesAccessorPostProcessor.rst | 60 +++ .../builtin/populatePostProcessor.rst | 65 ++++ .../generator/custom/customPostProcessor.rst | 46 +++ docs/source/generator/postProcessor.rst | 342 +----------------- docs/source/gettingStarted.rst | 2 +- docs/source/toc-generator.rst | 2 +- 11 files changed, 346 insertions(+), 339 deletions(-) create mode 100644 docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst create mode 100644 docs/source/generator/builtin/enumPostProcessor.rst create mode 100644 docs/source/generator/builtin/patternPropertiesAccessorPostProcessor.rst create mode 100644 docs/source/generator/builtin/populatePostProcessor.rst create mode 100644 docs/source/generator/custom/customPostProcessor.rst diff --git a/docs/source/complexTypes/enum.rst b/docs/source/complexTypes/enum.rst index c5128b35..c39cd79c 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 diff --git a/docs/source/complexTypes/object.rst b/docs/source/complexTypes/object.rst index db2c87c3..c88a200a 100644 --- a/docs/source/complexTypes/object.rst +++ b/docs/source/complexTypes/object.rst @@ -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 @@ -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..601059d2 --- /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): 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. diff --git a/docs/source/generator/builtin/enumPostProcessor.rst b/docs/source/generator/builtin/enumPostProcessor.rst new file mode 100644 index 00000000..22fe4b82 --- /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|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. diff --git a/docs/source/generator/builtin/patternPropertiesAccessorPostProcessor.rst b/docs/source/generator/builtin/patternPropertiesAccessorPostProcessor.rst new file mode 100644 index 00000000..283f4f14 --- /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): 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 `__ diff --git a/docs/source/generator/builtin/populatePostProcessor.rst b/docs/source/generator/builtin/populatePostProcessor.rst new file mode 100644 index 00000000..0908b8d3 --- /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): 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. 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..add70a30 100644 --- a/docs/source/generator/postProcessor.rst +++ b/docs/source/generator/postProcessor.rst @@ -13,337 +13,17 @@ 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/additionalPropertiesAccessorPostProcessor + builtin/patternPropertiesAccessorPostProcessor + builtin/enumPostProcessor + builtin/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): 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/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/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 From 03cbede6721b9eea459d9c1ad6226960ebcfa700 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 19 Sep 2025 15:25:12 +0200 Subject: [PATCH 11/16] Add docs Builder classes don't apply filters --- .github/workflows/main.yml | 2 +- composer.json | 1 - docs/source/combinedSchemas/allOf.rst | 2 +- docs/source/combinedSchemas/anyOf.rst | 2 +- docs/source/combinedSchemas/if.rst | 6 +- .../source/combinedSchemas/mergedProperty.rst | 10 +- docs/source/combinedSchemas/not.rst | 2 +- docs/source/combinedSchemas/oneOf.rst | 2 +- docs/source/complexTypes/array.rst | 8 +- docs/source/complexTypes/enum.rst | 4 +- docs/source/complexTypes/multiType.rst | 4 +- docs/source/complexTypes/object.rst | 26 +-- ...itionalPropertiesAccessorPostProcessor.rst | 14 +- .../builtin/builderClassPostProcessor.rst | 157 ++++++++++++++++++ .../generator/builtin/enumPostProcessor.rst | 6 +- ...patternPropertiesAccessorPostProcessor.rst | 4 +- .../builtin/populatePostProcessor.rst | 8 +- docs/source/generator/postProcessor.rst | 5 +- docs/source/generic/meta.rst | 4 +- docs/source/generic/readonly.rst | 2 +- docs/source/generic/references.rst | 2 +- docs/source/generic/required.rst | 4 +- docs/source/nonStandardExtensions/filter.rst | 8 +- docs/source/types/boolean.rst | 2 +- docs/source/types/const.rst | 2 +- docs/source/types/null.rst | 2 +- docs/source/types/number.rst | 4 +- docs/source/types/string.rst | 2 +- .../Filter/DateTimeFilter.php | 2 +- .../BuilderClassPostProcessor.php | 6 +- .../Templates/BuilderClass.phptpl | 4 +- src/Utils/RenderHelper.php | 14 +- 32 files changed, 241 insertions(+), 80 deletions(-) create mode 100644 docs/source/generator/builtin/builderClassPostProcessor.rst diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 162b058a..2a402d83 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: mbstring, json + extensions: mbstring coverage: ${{ matrix.coverage && 'xdebug' || 'none' }} - name: Install dependencies diff --git a/composer.json b/composer.json index 6cfacf27..2ef3f27e 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,6 @@ "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 c39cd79c..4c567111 100644 --- a/docs/source/complexTypes/enum.rst +++ b/docs/source/complexTypes/enum.rst @@ -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 c88a200a..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 ---- @@ -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:: diff --git a/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst b/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst index 601059d2..60a54b04 100644 --- a/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst +++ b/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst @@ -6,11 +6,11 @@ AdditionalPropertiesAccessorPostProcessor $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. +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 + 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 ~~~~~~~~~~~~~ @@ -36,19 +36,19 @@ Generated interface with the **AdditionalPropertiesAccessorPostProcessor**: public function getRawModelDataInput(): array; - public function setExample(float $example): self; + 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): self; + 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. + 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. +**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. @@ -59,4 +59,4 @@ Generated interface with the **AdditionalPropertiesAccessorPostProcessor**: 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. +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 index 22fe4b82..dc566d14 100644 --- a/docs/source/generator/builtin/enumPostProcessor.rst +++ b/docs/source/generator/builtin/enumPostProcessor.rst @@ -50,9 +50,9 @@ The type hints and annotations of the generated class will be changed to match t .. code-block:: php /** - * @param OfferState|string|null $state + * @param OfferState|string $state */ - public function setState($state): self; + public function setState($state): static; public function getState(): ?OfferState; Mapping @@ -91,4 +91,4 @@ If an enum which requires a mapping is found but no mapping is provided a **Sche .. 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. + 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 index 283f4f14..434c1e1c 100644 --- a/docs/source/generator/builtin/patternPropertiesAccessorPostProcessor.rst +++ b/docs/source/generator/builtin/patternPropertiesAccessorPostProcessor.rst @@ -6,7 +6,7 @@ PatternPropertiesAccessorPostProcessor $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. +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 ~~~~~~~~~~~~~ @@ -38,7 +38,7 @@ Generated interface with the **PatternPropertiesAccessorPostProcessor**: public function getRawModelDataInput(): array; - public function setExample(float $example): self; + public function setExample(float $example): static; public function getExample(): float; public function getPatternProperties(string $key): array; diff --git a/docs/source/generator/builtin/populatePostProcessor.rst b/docs/source/generator/builtin/populatePostProcessor.rst index 0908b8d3..8eb28bb1 100644 --- a/docs/source/generator/builtin/populatePostProcessor.rst +++ b/docs/source/generator/builtin/populatePostProcessor.rst @@ -6,7 +6,7 @@ PopulatePostProcessor $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. +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 @@ -26,10 +26,10 @@ Generated interface with the **PopulatePostProcessor**: public function getRawModelDataInput(): array; - public function setExample(float $example): self; + public function setExample(float $example): static; public function getExample(): float; - public function populate(array $modelData): self; + public function populate(array $modelData): static; Now let's have a look at the behaviour of the generated model: @@ -60,6 +60,6 @@ Now let's have a look at the behaviour of the generated model: .. 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>`__. + 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/postProcessor.rst b/docs/source/generator/postProcessor.rst index add70a30..2ed64b35 100644 --- a/docs/source/generator/postProcessor.rst +++ b/docs/source/generator/postProcessor.rst @@ -17,10 +17,11 @@ All added post processors will be executed after a schema was processed and befo :caption: Builtin Post Processors :maxdepth: 1 - builtin/additionalPropertiesAccessorPostProcessor - builtin/patternPropertiesAccessorPostProcessor + builtin/builderClassPostProcessor builtin/enumPostProcessor builtin/populatePostProcessor + builtin/additionalPropertiesAccessorPostProcessor + builtin/patternPropertiesAccessorPostProcessor .. toctree:: :caption: Custom Post Processors 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/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/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/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/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php index 06a818fb..b8148e85 100644 --- a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php @@ -14,7 +14,6 @@ use PHPModelGenerator\Model\Property\PropertyType; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\Validator; -use PHPModelGenerator\Model\Validator\FilterValidator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintTransferDecorator; use PHPModelGenerator\Utils\RenderHelper; @@ -55,10 +54,7 @@ public function postProcess(): void // ensure the getter methods for required properties can return null (they have not been set yet) ->setType($property->getType(), new PropertyType($property->getType(true)->getName(), true)) ->addTypeHintDecorator(new TypeHintTransferDecorator($property)) - // keep filters to ensure values set on the builder match the return type of the getter - ->filterValidators(static fn(Validator $validator): bool - => is_a($validator->getValidator(), FilterValidator::class) - ); + ->filterValidators(static fn(Validator $validator): bool => false); } } diff --git a/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl b/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl index 9e091e23..7a9f079e 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl @@ -65,10 +65,10 @@ class {{ class }}Builder implements BuilderInterface * * {% if property.getDescription() %}{{ property.getDescription() }}{% endif %} * - * {% if viewHelper.getTypeHintAnnotation(property, true) %}@return {{ viewHelper.getTypeHintAnnotation(property, true) }}{% endif %} + * {% if viewHelper.getTypeHintAnnotation(property) %}@return {{ viewHelper.getTypeHintAnnotation(property, false, true) }}{% endif %} */ public function get{{ viewHelper.ucfirst(property.getAttribute()) }}() - {% if property.getType(true) %}: {{ viewHelper.getType(property, true) }}{% endif %} + {% if property.getType() %}: {{ viewHelper.getType(property, false, true) }}{% endif %} { return $this->_rawModelDataInput['{{ property.getName() }}'] ?? null; } diff --git a/src/Utils/RenderHelper.php b/src/Utils/RenderHelper.php index 5545ac0c..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"; From 7864db744634014f2385b61f535422b15fc7a988 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 19 Sep 2025 16:01:12 +0200 Subject: [PATCH 12/16] remove unnecessary code --- src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php index b8148e85..8d9a45d0 100644 --- a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php @@ -51,8 +51,6 @@ public function postProcess(): void if (!$property->isInternal()) { $properties[] = (clone $property) ->setReadOnly(false) - // ensure the getter methods for required properties can return null (they have not been set yet) - ->setType($property->getType(), new PropertyType($property->getType(true)->getName(), true)) ->addTypeHintDecorator(new TypeHintTransferDecorator($property)) ->filterValidators(static fn(Validator $validator): bool => false); } From 1b7cc72254850e6950ca307cd1cdb124a1f95399 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 19 Sep 2025 16:57:30 +0200 Subject: [PATCH 13/16] Additional test cases --- .../BuilderClassPostProcessor.php | 9 +-- tests/AbstractPHPModelGeneratorTestCase.php | 7 +- .../BuilderClassPostProcessorTest.php | 66 ++++++++++++++----- tests/PostProcessor/EnumPostProcessorTest.php | 59 +++++++++++++++++ .../NestedObject.json | 16 ----- .../NestedObject/Dependencies/Address.json | 11 ++++ .../NestedObject/NestedObject.json | 8 +++ 7 files changed, 137 insertions(+), 39 deletions(-) delete mode 100644 tests/Schema/BuilderClassPostProcessorTest/NestedObject.json create mode 100644 tests/Schema/BuilderClassPostProcessorTest/NestedObject/Dependencies/Address.json create mode 100644 tests/Schema/BuilderClassPostProcessorTest/NestedObject/NestedObject.json diff --git a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php index 8d9a45d0..dae9dbfa 100644 --- a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php @@ -122,12 +122,7 @@ private function getBuilderClassImports(array $properties, array $originalClassI } } - // required for compatibility with the EnumPostProcessor - if (enum_exists($type)) { - array_push($imports, $type, UnitEnum::class); - } - - if (class_exists($type)) { + 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 @@ -138,6 +133,8 @@ private function getBuilderClassImports(array $properties, array $originalClassI )); $property->setType(); + + $imports[] = $type . 'Builder'; } } } diff --git a/tests/AbstractPHPModelGeneratorTestCase.php b/tests/AbstractPHPModelGeneratorTestCase.php index db6e8fd7..48ec2ac2 100644 --- a/tests/AbstractPHPModelGeneratorTestCase.php +++ b/tests/AbstractPHPModelGeneratorTestCase.php @@ -274,7 +274,12 @@ 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, ); diff --git a/tests/PostProcessor/BuilderClassPostProcessorTest.php b/tests/PostProcessor/BuilderClassPostProcessorTest.php index ba0509c1..2e47dc40 100644 --- a/tests/PostProcessor/BuilderClassPostProcessorTest.php +++ b/tests/PostProcessor/BuilderClassPostProcessorTest.php @@ -4,10 +4,15 @@ namespace PHPModelGenerator\Tests\PostProcessor; +use FilesystemIterator; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\ModelGenerator; use PHPModelGenerator\SchemaProcessor\PostProcessor\BuilderClassPostProcessor; +use PHPModelGenerator\SchemaProcessor\PostProcessor\EnumPostProcessor; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; class BuilderClassPostProcessorTest extends AbstractPHPModelGeneratorTestCase { @@ -103,26 +108,21 @@ public function testImplicitNull(): void public function testNestedObject(): void { - $className = $this->generateClassFromFile('NestedObject.json'); + $files = $this->generateDirectory( + 'NestedObject', + (new GeneratorConfiguration()) + ->setNamespacePrefix('MyApp\\Namespace\\') + ->setOutputEnabled(false) + ->setImplicitNull(true), + ); + $this->assertCount(2, $files); $this->assertGeneratedBuilders(2); - $builderClassName = $className . 'Builder'; + $builderClassName = 'MyApp\Namespace\NestedObjectBuilder'; $builderObject = new $builderClassName(); - $nestedObjectClassName = null; - foreach ($this->getGeneratedFiles() as $file) { - if (str_contains($file, 'Address')) { - $nestedObjectClassName = str_replace('.php', '', basename($file)); - - break; - } - } - - $nestedBuilderClassName = $nestedObjectClassName . 'Builder'; - - $this->assertNotEmpty($nestedObjectClassName); - $expectedTypeHint = "$nestedObjectClassName|$nestedBuilderClassName|array|null"; + $expectedTypeHint = "Address|AddressBuilder|array|null"; $this->assertSame($expectedTypeHint, $this->getParameterTypeAnnotation($builderObject, 'setAddress')); $this->assertSame($expectedTypeHint, $this->getReturnTypeAnnotation($builderObject, 'getAddress')); @@ -136,6 +136,7 @@ public function testNestedObject(): void $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')); @@ -151,12 +152,18 @@ public function testNestedObject(): void $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 @@ -201,10 +208,37 @@ public function testNestedObjectArray(): void } } + public function testEnum(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new BuilderClassPostProcessor())->addPostProcessor(new EnumPostProcessor()); + }; + $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')); + } + private function assertGeneratedBuilders(int $expectedGeneratedBuilders): void { $dir = sys_get_temp_dir() . '/PHPModelGeneratorTest/Models'; - $files = array_filter(scandir($dir), fn (string $file): bool => str_ends_with($file, 'Builder.php')); + + $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 aded2b64..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; @@ -585,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 [ diff --git a/tests/Schema/BuilderClassPostProcessorTest/NestedObject.json b/tests/Schema/BuilderClassPostProcessorTest/NestedObject.json deleted file mode 100644 index 6ea10d23..00000000 --- a/tests/Schema/BuilderClassPostProcessorTest/NestedObject.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "type": "object", - "properties": { - "address": { - "type": "object", - "properties": { - "street": { - "type": "string" - }, - "number": { - "type": "integer" - } - } - } - } -} \ 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 From 0b5a9e1a95e5a0d0bc74d593bd76b0feef372eb1 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 19 Sep 2025 16:59:03 +0200 Subject: [PATCH 14/16] Additional test cases --- .../BuilderClassPostProcessorTest.php | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/PostProcessor/BuilderClassPostProcessorTest.php b/tests/PostProcessor/BuilderClassPostProcessorTest.php index 2e47dc40..b1e3f826 100644 --- a/tests/PostProcessor/BuilderClassPostProcessorTest.php +++ b/tests/PostProcessor/BuilderClassPostProcessorTest.php @@ -208,22 +208,6 @@ public function testNestedObjectArray(): void } } - public function testEnum(): void - { - $this->modifyModelGenerator = static function (ModelGenerator $generator): void { - $generator->addPostProcessor(new BuilderClassPostProcessor())->addPostProcessor(new EnumPostProcessor()); - }; - $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')); - } - private function assertGeneratedBuilders(int $expectedGeneratedBuilders): void { $dir = sys_get_temp_dir() . '/PHPModelGeneratorTest/Models'; From c0a1f54aa062d2c2dfc855ba4b9b0dbda070499a Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 19 Sep 2025 16:59:32 +0200 Subject: [PATCH 15/16] remove unused import --- tests/PostProcessor/BuilderClassPostProcessorTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/PostProcessor/BuilderClassPostProcessorTest.php b/tests/PostProcessor/BuilderClassPostProcessorTest.php index b1e3f826..cba6e359 100644 --- a/tests/PostProcessor/BuilderClassPostProcessorTest.php +++ b/tests/PostProcessor/BuilderClassPostProcessorTest.php @@ -8,7 +8,6 @@ use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\ModelGenerator; use PHPModelGenerator\SchemaProcessor\PostProcessor\BuilderClassPostProcessor; -use PHPModelGenerator\SchemaProcessor\PostProcessor\EnumPostProcessor; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; From 53117fadc726d7a8ef1a1812cd32a9b659825e9c Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 19 Sep 2025 17:06:24 +0200 Subject: [PATCH 16/16] patch path evaluation --- .../PostProcessor/BuilderClassPostProcessor.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php index dae9dbfa..0386405e 100644 --- a/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/BuilderClassPostProcessor.php @@ -17,6 +17,7 @@ use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintTransferDecorator; use PHPModelGenerator\Utils\RenderHelper; +use ReflectionClass; use UnitEnum; class BuilderClassPostProcessor extends PostProcessor @@ -128,8 +129,9 @@ private function getBuilderClassImports(array $properties, array $originalClassI // 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( - [basename($type) . 'Builder' . (str_contains($typeAnnotation, '[]') ? '[]' : ''), 'array'], + [$builderClassName . (str_contains($typeAnnotation, '[]') ? '[]' : ''), 'array'], )); $property->setType();