From 2109f1056babe6c80aa4e93fcbfcacc0efb66d35 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 26 Mar 2026 01:35:21 +0100 Subject: [PATCH 1/9] Introduce Draft-based architecture (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DraftInterface/DraftFactoryInterface abstraction and wire it into GeneratorConfiguration. The pipeline does not yet use the draft — this phase establishes the structural foundation only. New classes: - DraftInterface: getDefinition(): DraftBuilder - DraftFactoryInterface: getDraftForSchema(JsonSchema): DraftInterface - DraftBuilder: fluent builder for Draft instances - Draft: built result with getCoveredTypes(string|array) — always includes 'any' - Element/Type: holds a type name and its ModifierInterface list - Modifier/ModifierInterface: modify() contract for future phases - Draft_07: registers all 7 JSON Schema types + 'any' (empty modifier lists) - AutoDetectionDraft: DraftFactoryInterface that detects $schema keyword, falls back to Draft_07 for absent/unrecognised values GeneratorConfiguration gains getDraft()/setDraft() accepting DraftInterface|DraftFactoryInterface; defaults to AutoDetectionDraft. phpcs.xml: exclude Squiz.Classes.ValidClassName.NotPascalCase to allow underscore-separated draft class names (Draft_07, Draft_2020_12, etc.). --- docs/source/gettingStarted.rst | 54 +++++++++++++++ phpcs.xml | 2 + src/Draft/AutoDetectionDraft.php | 18 +++++ src/Draft/Draft.php | 62 +++++++++++++++++ src/Draft/DraftBuilder.php | 30 ++++++++ src/Draft/DraftFactoryInterface.php | 12 ++++ src/Draft/DraftInterface.php | 10 +++ src/Draft/Draft_07.php | 23 +++++++ src/Draft/Element/Type.php | 37 ++++++++++ src/Draft/Modifier/ModifierInterface.php | 20 ++++++ src/Model/GeneratorConfiguration.php | 21 +++++- tests/Draft/DraftTest.php | 88 ++++++++++++++++++++++++ 12 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 src/Draft/AutoDetectionDraft.php create mode 100644 src/Draft/Draft.php create mode 100644 src/Draft/DraftBuilder.php create mode 100644 src/Draft/DraftFactoryInterface.php create mode 100644 src/Draft/DraftInterface.php create mode 100644 src/Draft/Draft_07.php create mode 100644 src/Draft/Element/Type.php create mode 100644 src/Draft/Modifier/ModifierInterface.php create mode 100644 tests/Draft/DraftTest.php diff --git a/docs/source/gettingStarted.rst b/docs/source/gettingStarted.rst index 41b50361..4302f772 100644 --- a/docs/source/gettingStarted.rst +++ b/docs/source/gettingStarted.rst @@ -309,6 +309,60 @@ The output of a generation process may look like: Rendered class MyApp\User\Response\Login Rendered class MyApp\User\Response\Register +JSON Schema draft version +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: php + + setDraft(DraftInterface|DraftFactoryInterface $draft); + +Controls which JSON Schema draft version is used during generation. Accepts either a concrete +draft instance (``DraftInterface``) to pin all schemas to one draft, or a factory +(``DraftFactoryInterface``) to select the draft per schema file. + +By default ``AutoDetectionDraft`` is used. It implements ``DraftFactoryInterface`` and inspects +the ``$schema`` keyword of each schema file to select the appropriate draft automatically. When +the keyword is absent or unrecognised, it falls back to JSON Schema Draft 7 behaviour, so schemas +with different ``$schema`` declarations in the same generation run can use different drafts. + +To pin all schemas to a specific draft: + +.. code-block:: php + + use PHPModelGenerator\Draft\Draft_07; + + (new GeneratorConfiguration()) + ->setDraft(new Draft_07()); + +To use a custom draft factory that selects the draft based on your own logic: + +.. code-block:: php + + use PHPModelGenerator\Draft\DraftFactoryInterface; + use PHPModelGenerator\Draft\DraftInterface; + use PHPModelGenerator\Draft\Draft_07; + use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; + + class MyDraftFactory implements DraftFactoryInterface + { + public function getDraftForSchema(JsonSchema $jsonSchema): DraftInterface + { + // select draft based on schema content + return new Draft_07(); + } + } + + (new GeneratorConfiguration()) + ->setDraft(new MyDraftFactory()); + +Available draft classes: + +============= ================================ +Draft class Description +============= ================================ +``Draft_07`` JSON Schema Draft 7 (default) +============= ================================ + Custom filter ^^^^^^^^^^^^^ diff --git a/phpcs.xml b/phpcs.xml index e2912910..83f5f22f 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -12,5 +12,7 @@ + + diff --git a/src/Draft/AutoDetectionDraft.php b/src/Draft/AutoDetectionDraft.php new file mode 100644 index 00000000..7ee2b06c --- /dev/null +++ b/src/Draft/AutoDetectionDraft.php @@ -0,0 +1,18 @@ +types; + } + + /** + * Returns the Type entries whose modifiers apply to a property of the given type(s). + * The special type 'any' always applies to every property; passing 'any' returns all types. + * + * @param string|string[] $type + * + * @return Type[] + * + * @throws SchemaException + */ + public function getCoveredTypes(string | array $type): array + { + if (!is_array($type)) { + $type = [$type]; + } + + if (in_array('any', $type, true)) { + return $this->types; + } + + // 'any' modifiers always apply regardless of the concrete type + $type[] = 'any'; + + $unknownTypes = array_diff($type, array_keys($this->types)); + if ($unknownTypes) { + throw new SchemaException(sprintf( + 'Unsupported property type %s', + count($unknownTypes) === 1 + ? reset($unknownTypes) + : '[' . implode(',', $unknownTypes) . ']', + )); + } + + return array_intersect_key($this->types, array_fill_keys($type, null)); + } +} diff --git a/src/Draft/DraftBuilder.php b/src/Draft/DraftBuilder.php new file mode 100644 index 00000000..d8880751 --- /dev/null +++ b/src/Draft/DraftBuilder.php @@ -0,0 +1,30 @@ +types[$type->getType()] = $type; + + return $this; + } + + public function getType(string $type): ?Type + { + return $this->types[$type] ?? null; + } + + public function build(): Draft + { + return new Draft($this->types); + } +} diff --git a/src/Draft/DraftFactoryInterface.php b/src/Draft/DraftFactoryInterface.php new file mode 100644 index 00000000..4db9e02b --- /dev/null +++ b/src/Draft/DraftFactoryInterface.php @@ -0,0 +1,12 @@ +addType(new Type('object')) + ->addType(new Type('array')) + ->addType(new Type('string')) + ->addType(new Type('integer')) + ->addType(new Type('number')) + ->addType(new Type('boolean')) + ->addType(new Type('null')) + ->addType(new Type('any')); + } +} diff --git a/src/Draft/Element/Type.php b/src/Draft/Element/Type.php new file mode 100644 index 00000000..f78ed577 --- /dev/null +++ b/src/Draft/Element/Type.php @@ -0,0 +1,37 @@ +modifiers[] = $modifier; + + return $this; + } + + public function getType(): string + { + return $this->type; + } + + /** + * @return ModifierInterface[] + */ + public function getModifiers(): array + { + return $this->modifiers; + } +} diff --git a/src/Draft/Modifier/ModifierInterface.php b/src/Draft/Modifier/ModifierInterface.php new file mode 100644 index 00000000..e113a013 --- /dev/null +++ b/src/Draft/Modifier/ModifierInterface.php @@ -0,0 +1,20 @@ +draft = new AutoDetectionDraft(); $this->classNameGenerator = new ClassNameGenerator(); // add all built-in filter and format validators @@ -244,6 +251,18 @@ public function setErrorRegistryClass(string $errorRegistryClass): self return $this; } + public function getDraft(): DraftInterface | DraftFactoryInterface + { + return $this->draft; + } + + public function setDraft(DraftInterface | DraftFactoryInterface $draft): self + { + $this->draft = $draft; + + return $this; + } + public function isImplicitNullAllowed(): bool { return $this->allowImplicitNull; diff --git a/tests/Draft/DraftTest.php b/tests/Draft/DraftTest.php new file mode 100644 index 00000000..7cde1e94 --- /dev/null +++ b/tests/Draft/DraftTest.php @@ -0,0 +1,88 @@ +expectException(SchemaException::class); + + (new Draft_07())->getDefinition()->build()->getCoveredTypes('nonexistent'); + } + + // --- AutoDetectionDraft --- + + public function testAutoDetectionReturnsDraft07ForDraft07SchemaKeyword(): void + { + $jsonSchema = new JsonSchema('test.json', [ + '$schema' => 'http://json-schema.org/draft-07/schema#', + ]); + + $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + } + + public function testAutoDetectionFallsBackToDraft07WhenSchemaKeywordAbsent(): void + { + $jsonSchema = new JsonSchema('test.json', ['type' => 'object']); + + $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + } + + public function testAutoDetectionFallsBackToDraft07ForUnrecognisedSchemaKeyword(): void + { + $jsonSchema = new JsonSchema('test.json', ['$schema' => 'https://example.com/custom-schema']); + + $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + } + + // --- GeneratorConfiguration --- + + public function testGeneratorConfigurationDefaultDraftIsAutoDetection(): void + { + $this->assertInstanceOf(AutoDetectionDraft::class, (new GeneratorConfiguration())->getDraft()); + } + + public function testGeneratorConfigurationAcceptsDraftInterface(): void + { + $draft = new Draft_07(); + $config = (new GeneratorConfiguration())->setDraft($draft); + + $this->assertSame($draft, $config->getDraft()); + } + + public function testGeneratorConfigurationAcceptsDraftFactoryInterface(): void + { + $factory = new AutoDetectionDraft(); + $config = (new GeneratorConfiguration())->setDraft($factory); + + $this->assertSame($factory, $config->getDraft()); + } + + public function testGeneratorConfigurationSetDraftReturnsSelf(): void + { + $config = new GeneratorConfiguration(); + + $this->assertSame($config, $config->setDraft(new Draft_07())); + } + + public function testGeneratorConfigurationSetDraftFactoryReturnsSelf(): void + { + $config = new GeneratorConfiguration(); + + $this->assertSame($config, $config->setDraft(new AutoDetectionDraft())); + } +} From 116291fccb5b23457c2ba5848ffa93b2288c2676 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 27 Mar 2026 01:26:47 +0100 Subject: [PATCH 2/9] Implement Phase 2 & 3: eliminate PropertyMetaDataCollection, refine Draft architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 — Eliminate PropertyMetaDataCollection: - Add bool $required parameter to PropertyFactory::create, ProcessorFactoryInterface::getProcessor and all processor constructors - Replace isAttributeRequired() lookups with $this->required throughout - Move addDependencyValidator to BaseProcessor::addPropertiesToSchema, reading directly from $json['dependencies'][$propertyName] - Update SchemaDefinition::resolveReference to accept bool $required instead of PropertyMetaDataCollection; simplify cache key accordingly - Remove PMC save/restore from AdditionalPropertiesValidator, PatternPropertiesValidator, ArrayTupleValidator, AbstractComposedValueProcessor, and IfProcessor - Delete PropertyMetaDataCollection entirely Phase 3 refinements — Draft architecture: - DraftInterface.getDefinition() returns DraftBuilder (not Draft), enabling draft extension by consumers; PropertyFactory builds and caches - Type constructor auto-installs TypeCheckModifier via TypeConverter::jsonSchemaToPhp, removing per-type boilerplate from Draft_07 - DefaultValueModifier is self-contained: reads type from schema, resolves is_* check and int->float coercion internally; no callable constructor API - Draft_07 is now a clean declarative list with no implementation detail - Replace DRAFT_BYPASS_TYPES hardcoded list with Draft::hasType() check - Add TypeConverter::jsonSchemaToPhp() to centralise JSON->PHP type mapping --- CLAUDE.md | 26 ++- src/Draft/AutoDetectionDraft.php | 5 +- src/Draft/Draft.php | 5 + src/Draft/Draft_07.php | 5 +- src/Draft/Element/Type.php | 7 +- src/Draft/Modifier/DefaultValueModifier.php | 59 ++++++ src/Draft/Modifier/TypeCheckModifier.php | 43 +++++ .../SchemaDefinition/SchemaDefinition.php | 11 +- .../AdditionalPropertiesValidator.php | 3 +- src/Model/Validator/ArrayItemValidator.php | 2 - src/Model/Validator/ArrayTupleValidator.php | 3 +- .../Validator/PatternPropertiesValidator.php | 3 +- .../Validator/PropertyNamesValidator.php | 3 +- .../AbstractComposedValueProcessor.php | 20 +- .../ComposedValue/IfProcessor.php | 3 +- .../ComposedValueProcessorFactory.php | 4 +- .../ProcessorFactoryInterface.php | 4 +- .../Property/AbstractPropertyProcessor.php | 69 +------ .../Property/AbstractTypedValueProcessor.php | 17 +- .../Property/AbstractValueProcessor.php | 7 +- .../Property/ArrayProcessor.php | 4 - .../Property/BaseProcessor.php | 88 +++++++-- .../Property/ConstProcessor.php | 2 +- .../Property/MultiTypeProcessor.php | 7 +- .../Property/ReferenceProcessor.php | 7 +- src/PropertyProcessor/PropertyFactory.php | 65 ++++++- .../PropertyMetaDataCollection.php | 51 ------ .../PropertyProcessorFactory.php | 17 +- src/SchemaProcessor/SchemaProcessor.php | 2 - src/Utils/TypeConverter.php | 9 + .../Modifier/DefaultValueModifierTest.php | 121 ++++++++++++ .../Draft/Modifier/TypeCheckModifierTest.php | 172 ++++++++++++++++++ tests/Objects/ReferencePropertyTest.php | 3 +- .../PropertyProcessorFactoryTest.php | 3 - 34 files changed, 646 insertions(+), 204 deletions(-) create mode 100644 src/Draft/Modifier/DefaultValueModifier.php create mode 100644 src/Draft/Modifier/TypeCheckModifier.php delete mode 100644 src/PropertyProcessor/PropertyMetaDataCollection.php create mode 100644 tests/Draft/Modifier/DefaultValueModifierTest.php create mode 100644 tests/Draft/Modifier/TypeCheckModifierTest.php diff --git a/CLAUDE.md b/CLAUDE.md index ce96adc2..20567ad1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,10 @@ user to resolve it. Rules: -- Ask all foreseeable clarifying questions upfront in a single batch before work begins. +- When there are multiple clarifying questions to ask, ask them **one at a time**, in order of + dependency (earlier answers may resolve later questions). Wait for the answer before asking the + next question. This allows the user to discuss each point in depth without being overwhelmed by + a wall of questions. - If new ambiguities emerge during execution that were not foreseeable upfront, pause and ask follow-up questions before proceeding past that decision point. - For high-stakes decisions (architecture, scope, data model, API shape, behaviour changes) always @@ -19,6 +22,9 @@ Rules: visible so the user can correct it. - There must be no silent interpretation or interpolation of under-specified tasks. If something is unclear, ask. Do not guess and proceed. +- For multi-phase implementations, **never start the next phase without an explicit go-ahead from + the user**. After completing a phase, summarise what was done and wait for confirmation before + proceeding. When generating a new CLAUDE.md for a repository, include this clarification policy verbatim as a preamble before all other content. @@ -48,6 +54,20 @@ composer update Tests write generated PHP classes to `sys_get_temp_dir()/PHPModelGeneratorTest/Models/` and dump failed classes to `./failed-classes/` (auto-cleaned on bootstrap). +### Running the full test suite + +When running the full test suite (all 2246 tests), always save output to a file so the complete +output is available for analysis without re-running. Use `--display-warnings` to capture warning +details and `--no-coverage` to skip slow coverage collection: + +```bash +php -d memory_limit=128M ./vendor/bin/phpunit --no-coverage --display-warnings 2>&1 | sed 's/\x1b\[[0-9;]*m//g' > /tmp/phpunit-output.txt; tail -5 /tmp/phpunit-output.txt +``` + +Then analyse with: `grep -E "FAIL|ERROR|WARN|Tests:" /tmp/phpunit-output.txt` + +After analysis is complete, delete the file: `rm /tmp/phpunit-output.txt` + ## Architecture This library generates PHP model classes from JSON Schema files. The process is a 4-step pipeline: @@ -131,6 +151,10 @@ property, duplicate property names with unresolvable type conflicts, and any oth that cannot produce a correct PHP model. Fail loudly at generation time so the developer sees the problem immediately rather than receiving silently incorrect generated code. +### Reading files + +Always use the dedicated `Read` tool to read file contents. Never use `sed`, `head`, `tail`, `cat`, or `awk` to read or extract portions of files. The `Read` tool supports `offset` and `limit` parameters for reading partial files when needed. + ### PHP import style Always add `use` imports for every class referenced in a file, including global PHP classes such as diff --git a/src/Draft/AutoDetectionDraft.php b/src/Draft/AutoDetectionDraft.php index 7ee2b06c..edcc8681 100644 --- a/src/Draft/AutoDetectionDraft.php +++ b/src/Draft/AutoDetectionDraft.php @@ -8,11 +8,14 @@ class AutoDetectionDraft implements DraftFactoryInterface { + /** @var DraftInterface[] Keyed by draft class name; reused across schemas */ + private array $draftInstances = []; + public function getDraftForSchema(JsonSchema $jsonSchema): DraftInterface { // Only Draft_07 is currently supported; all schemas (including unrecognised // or absent $schema keywords) fall back to it. Additional drafts will be // detected here in later phases (e.g. draft-04, draft 2020-12). - return new Draft_07(); + return $this->draftInstances[Draft_07::class] ??= new Draft_07(); } } diff --git a/src/Draft/Draft.php b/src/Draft/Draft.php index d78ab638..7cde1d58 100644 --- a/src/Draft/Draft.php +++ b/src/Draft/Draft.php @@ -24,6 +24,11 @@ public function getTypes(): array return $this->types; } + public function hasType(string $type): bool + { + return isset($this->types[$type]); + } + /** * Returns the Type entries whose modifiers apply to a property of the given type(s). * The special type 'any' always applies to every property; passing 'any' returns all types. diff --git a/src/Draft/Draft_07.php b/src/Draft/Draft_07.php index 56ea64c1..92b5c133 100644 --- a/src/Draft/Draft_07.php +++ b/src/Draft/Draft_07.php @@ -5,19 +5,20 @@ namespace PHPModelGenerator\Draft; use PHPModelGenerator\Draft\Element\Type; +use PHPModelGenerator\Draft\Modifier\DefaultValueModifier; class Draft_07 implements DraftInterface { public function getDefinition(): DraftBuilder { return (new DraftBuilder()) - ->addType(new Type('object')) + ->addType(new Type('object', false)) ->addType(new Type('array')) ->addType(new Type('string')) ->addType(new Type('integer')) ->addType(new Type('number')) ->addType(new Type('boolean')) ->addType(new Type('null')) - ->addType(new Type('any')); + ->addType((new Type('any', false))->addModifier(new DefaultValueModifier())); } } diff --git a/src/Draft/Element/Type.php b/src/Draft/Element/Type.php index f78ed577..6e5056f7 100644 --- a/src/Draft/Element/Type.php +++ b/src/Draft/Element/Type.php @@ -5,14 +5,19 @@ namespace PHPModelGenerator\Draft\Element; use PHPModelGenerator\Draft\Modifier\ModifierInterface; +use PHPModelGenerator\Draft\Modifier\TypeCheckModifier; +use PHPModelGenerator\Utils\TypeConverter; class Type { /** @var ModifierInterface[] */ private array $modifiers = []; - public function __construct(private readonly string $type) + public function __construct(private readonly string $type, bool $typeCheck = true) { + if ($typeCheck) { + $this->modifiers[] = new TypeCheckModifier(TypeConverter::jsonSchemaToPhp($type)); + } } public function addModifier(ModifierInterface $modifier): self diff --git a/src/Draft/Modifier/DefaultValueModifier.php b/src/Draft/Modifier/DefaultValueModifier.php new file mode 100644 index 00000000..e4ff88d7 --- /dev/null +++ b/src/Draft/Modifier/DefaultValueModifier.php @@ -0,0 +1,59 @@ +getJson(); + + if (!array_key_exists('default', $json)) { + return; + } + + $default = $json['default']; + $types = isset($json['type']) ? (array) $json['type'] : []; + + if (empty($types)) { + $property->setDefaultValue($default); + return; + } + + foreach ($types as $jsonType) { + $phpType = TypeConverter::jsonSchemaToPhp($jsonType); + + // Allow integer literals as defaults for 'number' (float) properties + if ($phpType === 'float' && is_int($default)) { + $default = (float) $default; + } + + $typeCheckFn = 'is_' . $phpType; + if (function_exists($typeCheckFn) && $typeCheckFn($default)) { + $property->setDefaultValue($default); + return; + } + } + + throw new SchemaException( + sprintf( + 'Invalid type for default value of property %s in file %s', + $property->getName(), + $propertySchema->getFile(), + ), + ); + } +} diff --git a/src/Draft/Modifier/TypeCheckModifier.php b/src/Draft/Modifier/TypeCheckModifier.php new file mode 100644 index 00000000..9089d55e --- /dev/null +++ b/src/Draft/Modifier/TypeCheckModifier.php @@ -0,0 +1,43 @@ +getValidators() as $validator) { + if ( + $validator->getValidator() instanceof TypeCheckInterface && + in_array($this->type, $validator->getValidator()->getTypes(), true) + ) { + return; + } + } + + $property->addValidator( + new TypeCheckValidator( + $this->type, + $property, + $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() && !$property->isRequired(), + ), + 2, + ); + } +} diff --git a/src/Model/SchemaDefinition/SchemaDefinition.php b/src/Model/SchemaDefinition/SchemaDefinition.php index 8f5dc7b6..a1217dd2 100644 --- a/src/Model/SchemaDefinition/SchemaDefinition.php +++ b/src/Model/SchemaDefinition/SchemaDefinition.php @@ -9,7 +9,6 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Property\PropertyProxy; use PHPModelGenerator\Model\Schema; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -52,7 +51,8 @@ public function getSchema(): Schema public function resolveReference( string $propertyName, array $path, - PropertyMetaDataCollection $propertyMetaDataCollection, + bool $required, + ?array $dependencies = null, ): PropertyInterface { $jsonSchema = $this->source->getJson(); $originalPath = $path; @@ -67,7 +67,7 @@ public function resolveReference( // if the properties point to the same definition and share identical metadata the generated property can be // recycled. Otherwise, a new property must be generated as diverging metadata lead to different validators. - $key = implode('-', [...$originalPath, $propertyMetaDataCollection->getHash($propertyName)]); + $key = implode('-', [...$originalPath, $required ? '1' : '0', md5(json_encode($dependencies))]); if (!$this->resolvedPaths->offsetExists($key)) { // create a dummy entry for the path first. If the path is used recursive the recursive usages will point @@ -75,14 +75,15 @@ public function resolveReference( $this->resolvedPaths->offsetSet($key, null); try { - $property = (new PropertyFactory(new PropertyProcessorFactory())) + $property = (new PropertyFactory(new PropertyProcessorFactory())) ->create( - $propertyMetaDataCollection, $this->schemaProcessor, $this->schema, $propertyName, $this->source->withJson($jsonSchema), + $required, ); + $this->resolvedPaths->offsetSet($key, $property); /** @var PropertyProxy $proxy */ diff --git a/src/Model/Validator/AdditionalPropertiesValidator.php b/src/Model/Validator/AdditionalPropertiesValidator.php index c9a0938d..1a63f2c0 100644 --- a/src/Model/Validator/AdditionalPropertiesValidator.php +++ b/src/Model/Validator/AdditionalPropertiesValidator.php @@ -10,7 +10,6 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -47,11 +46,11 @@ public function __construct( $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); $this->validationProperty = $propertyFactory->create( - new PropertyMetaDataCollection([static::PROPERTY_NAME]), $schemaProcessor, $schema, static::PROPERTY_NAME, $propertiesStructure->withJson($propertiesStructure->getJson()[static::ADDITIONAL_PROPERTIES_KEY]), + true, ); $this->validationProperty->onResolve(function (): void { diff --git a/src/Model/Validator/ArrayItemValidator.php b/src/Model/Validator/ArrayItemValidator.php index 14b95587..117c3de9 100644 --- a/src/Model/Validator/ArrayItemValidator.php +++ b/src/Model/Validator/ArrayItemValidator.php @@ -10,7 +10,6 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ArrayTypeHintDecorator; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -43,7 +42,6 @@ public function __construct( // an item of the array behaves like a nested property to add item-level validation $this->nestedProperty = (new PropertyFactory(new PropertyProcessorFactory())) ->create( - new PropertyMetaDataCollection(), $schemaProcessor, $schema, $nestedPropertyName, diff --git a/src/Model/Validator/ArrayTupleValidator.php b/src/Model/Validator/ArrayTupleValidator.php index 487b1657..09ef1b0f 100644 --- a/src/Model/Validator/ArrayTupleValidator.php +++ b/src/Model/Validator/ArrayTupleValidator.php @@ -10,7 +10,6 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -46,11 +45,11 @@ public function __construct( // an item of the array behaves like a nested property to add item-level validation $tupleProperty = $propertyFactory->create( - new PropertyMetaDataCollection([$tupleItemName]), $schemaProcessor, $schema, $tupleItemName, $propertiesStructure->withJson($tupleItem), + true, ); $this->tupleProperties[] = $tupleProperty; diff --git a/src/Model/Validator/PatternPropertiesValidator.php b/src/Model/Validator/PatternPropertiesValidator.php index c0cf29bc..ca7441dc 100644 --- a/src/Model/Validator/PatternPropertiesValidator.php +++ b/src/Model/Validator/PatternPropertiesValidator.php @@ -10,7 +10,6 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -42,11 +41,11 @@ public function __construct( $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); $this->validationProperty = $propertyFactory->create( - new PropertyMetaDataCollection(['pattern property']), $schemaProcessor, $schema, 'pattern property', $propertyStructure, + true, ); $this->validationProperty->onResolve(function (): void { diff --git a/src/Model/Validator/PropertyNamesValidator.php b/src/Model/Validator/PropertyNamesValidator.php index 732e5d42..8f3b5d99 100644 --- a/src/Model/Validator/PropertyNamesValidator.php +++ b/src/Model/Validator/PropertyNamesValidator.php @@ -12,7 +12,6 @@ use PHPModelGenerator\Model\Validator; use PHPModelGenerator\PropertyProcessor\Property\ConstProcessor; use PHPModelGenerator\PropertyProcessor\Property\StringProcessor; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -43,7 +42,7 @@ public function __construct( throw new SchemaException("Invalid const property name in file {$propertiesNames->getFile()}"); } - $nameValidationProperty = (new $processor(new PropertyMetaDataCollection(), $schemaProcessor, $schema)) + $nameValidationProperty = (new $processor($schemaProcessor, $schema)) ->process('property name', $propertiesNames) // the property name validator doesn't need type checks or required checks so simply filter them out ->filterValidators(static fn(Validator $validator): bool => diff --git a/src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php b/src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php index a0ac98fe..50e3cfdd 100644 --- a/src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php +++ b/src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php @@ -16,7 +16,6 @@ use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ClearTypeHintDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator; use PHPModelGenerator\PropertyProcessor\Property\AbstractValueProcessor; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -35,12 +34,12 @@ abstract class AbstractComposedValueProcessor extends AbstractValueProcessor * AbstractComposedValueProcessor constructor. */ public function __construct( - PropertyMetaDataCollection $propertyMetaDataCollection, SchemaProcessor $schemaProcessor, Schema $schema, + bool $required = false, private readonly bool $rootLevelComposition, ) { - parent::__construct($propertyMetaDataCollection, $schemaProcessor, $schema); + parent::__construct($schemaProcessor, $schema, $required); } /** @@ -134,14 +133,13 @@ protected function getCompositionProperties(PropertyInterface $property, JsonSch $compositionProperty = new CompositionPropertyDecorator( $property->getName(), $compositionSchema, - $propertyFactory - ->create( - new PropertyMetaDataCollection([$property->getName() => $property->isRequired()]), - $this->schemaProcessor, - $this->schema, - $property->getName(), - $compositionSchema, - ) + $propertyFactory->create( + $this->schemaProcessor, + $this->schema, + $property->getName(), + $compositionSchema, + $property->isRequired(), + ), ); $compositionProperty->onResolve(function () use ($compositionProperty, $property): void { diff --git a/src/PropertyProcessor/ComposedValue/IfProcessor.php b/src/PropertyProcessor/ComposedValue/IfProcessor.php index e04cb7ed..8cf6613c 100644 --- a/src/PropertyProcessor/ComposedValue/IfProcessor.php +++ b/src/PropertyProcessor/ComposedValue/IfProcessor.php @@ -13,7 +13,6 @@ use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator; use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; use PHPModelGenerator\PropertyProcessor\Property\AbstractValueProcessor; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\Utils\RenderHelper; @@ -59,11 +58,11 @@ protected function generateValidators(PropertyInterface $property, JsonSchema $p $compositionSchema, $propertyFactory ->create( - new PropertyMetaDataCollection([$property->getName() => $property->isRequired()]), $this->schemaProcessor, $this->schema, $property->getName(), $compositionSchema, + $property->isRequired(), ) ); diff --git a/src/PropertyProcessor/ComposedValueProcessorFactory.php b/src/PropertyProcessor/ComposedValueProcessorFactory.php index 934de5ef..d8727319 100644 --- a/src/PropertyProcessor/ComposedValueProcessorFactory.php +++ b/src/PropertyProcessor/ComposedValueProcessorFactory.php @@ -31,13 +31,13 @@ public function __construct(private readonly bool $rootLevelComposition) */ public function getProcessor( $type, - PropertyMetaDataCollection $propertyMetaDataCollection, SchemaProcessor $schemaProcessor, Schema $schema, + bool $required = false, ): PropertyProcessorInterface { $processor = '\\PHPModelGenerator\\PropertyProcessor\\ComposedValue\\' . ucfirst($type) . 'Processor'; - $params = [$propertyMetaDataCollection, $schemaProcessor, $schema]; + $params = [$schemaProcessor, $schema, $required]; if (is_a($processor, AbstractComposedValueProcessor::class, true)) { $params[] = $this->rootLevelComposition; diff --git a/src/PropertyProcessor/ProcessorFactoryInterface.php b/src/PropertyProcessor/ProcessorFactoryInterface.php index 2484dada..3e239702 100644 --- a/src/PropertyProcessor/ProcessorFactoryInterface.php +++ b/src/PropertyProcessor/ProcessorFactoryInterface.php @@ -15,12 +15,12 @@ interface ProcessorFactoryInterface { /** - * @param string|array $type + * @param string|array $type */ public function getProcessor( $type, - PropertyMetaDataCollection $propertyMetaDataCollection, SchemaProcessor $schemaProcessor, Schema $schema, + bool $required = false, ): PropertyProcessorInterface; } diff --git a/src/PropertyProcessor/Property/AbstractPropertyProcessor.php b/src/PropertyProcessor/Property/AbstractPropertyProcessor.php index c562df10..b4634e28 100644 --- a/src/PropertyProcessor/Property/AbstractPropertyProcessor.php +++ b/src/PropertyProcessor/Property/AbstractPropertyProcessor.php @@ -11,14 +11,10 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator\EnumValidator; -use PHPModelGenerator\Model\Validator\PropertyDependencyValidator; use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; -use PHPModelGenerator\Model\Validator\SchemaDependencyValidator; use PHPModelGenerator\PropertyProcessor\ComposedValueProcessorFactory; -use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintTransferDecorator; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorInterface; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -32,9 +28,9 @@ abstract class AbstractPropertyProcessor implements PropertyProcessorInterface { public function __construct( - protected PropertyMetaDataCollection $propertyMetaDataCollection, protected SchemaProcessor $schemaProcessor, - protected Schema $schema + protected Schema $schema, + protected bool $required = false, ) {} /** @@ -44,10 +40,6 @@ public function __construct( */ protected function generateValidators(PropertyInterface $property, JsonSchema $propertySchema): void { - if ($dependencies = $this->propertyMetaDataCollection->getAttributeDependencies($property->getName())) { - $this->addDependencyValidator($property, $dependencies); - } - if ($property->isRequired() && !str_starts_with($property->getName(), 'item of array ')) { $property->addValidator(new RequiredPropertyValidator($property), 1); } @@ -125,61 +117,6 @@ protected function addEnumValidator(PropertyInterface $property, array $allowedV $property->addValidator(new EnumValidator($property, $allowedValues), 3); } - /** - * @throws SchemaException - */ - protected function addDependencyValidator(PropertyInterface $property, array $dependencies): void - { - // check if we have a simple list of properties which must be present if the current property is present - $propertyDependency = true; - - array_walk( - $dependencies, - static function ($dependency, $index) use (&$propertyDependency): void { - $propertyDependency = $propertyDependency && is_int($index) && is_string($dependency); - }, - ); - - if ($propertyDependency) { - $property->addValidator(new PropertyDependencyValidator($property, $dependencies)); - - return; - } - - if (!isset($dependencies['type'])) { - $dependencies['type'] = 'object'; - } - - $dependencySchema = $this->schemaProcessor->processSchema( - new JsonSchema($this->schema->getJsonSchema()->getFile(), $dependencies), - $this->schema->getClassPath(), - "{$this->schema->getClassName()}_{$property->getName()}_Dependency", - $this->schema->getSchemaDictionary(), - ); - - $property->addValidator(new SchemaDependencyValidator($this->schemaProcessor, $property, $dependencySchema)); - $this->schema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($dependencySchema)); - - $this->transferDependentPropertiesToBaseSchema($dependencySchema); - } - - /** - * Transfer all properties from $dependencySchema to the base schema of the current property - */ - private function transferDependentPropertiesToBaseSchema(Schema $dependencySchema): void - { - foreach ($dependencySchema->getProperties() as $property) { - $this->schema->addProperty( - // validators and types must not be transferred as any value is acceptable for the property if the - // property defining the dependency isn't present - (clone $property) - ->setRequired(false) - ->setType(null) - ->filterValidators(static fn(): bool => false), - ); - } - } - /** * @throws SchemaException */ @@ -207,7 +144,6 @@ protected function addComposedValueValidator(PropertyInterface $property, JsonSc $composedProperty = $propertyFactory ->create( - $this->propertyMetaDataCollection, $this->schemaProcessor, $this->schema, $property->getName(), @@ -218,6 +154,7 @@ protected function addComposedValueValidator(PropertyInterface $property, JsonSc (!$property->isRequired() && $this->schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()), ]), + $property->isRequired(), ); foreach ($composedProperty->getValidators() as $validator) { diff --git a/src/PropertyProcessor/Property/AbstractTypedValueProcessor.php b/src/PropertyProcessor/Property/AbstractTypedValueProcessor.php index 92c1e833..3f628ccb 100644 --- a/src/PropertyProcessor/Property/AbstractTypedValueProcessor.php +++ b/src/PropertyProcessor/Property/AbstractTypedValueProcessor.php @@ -8,8 +8,8 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; +use PHPModelGenerator\Model\Validator\TypeCheckInterface; use PHPModelGenerator\Model\Validator\TypeCheckValidator; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; /** @@ -25,11 +25,11 @@ abstract class AbstractTypedValueProcessor extends AbstractValueProcessor * AbstractTypedValueProcessor constructor. */ public function __construct( - PropertyMetaDataCollection $propertyMetaDataCollection, SchemaProcessor $schemaProcessor, Schema $schema, + bool $required = false, ) { - parent::__construct($propertyMetaDataCollection, $schemaProcessor, $schema, static::TYPE); + parent::__construct($schemaProcessor, $schema, $required, static::TYPE); } /** @@ -76,6 +76,17 @@ protected function generateValidators(PropertyInterface $property, JsonSchema $p { parent::generateValidators($property, $propertySchema); + // Skip adding TypeCheckValidator if a Draft modifier already added one for this type, + // preventing duplicate type-check validators during the bridge period. + foreach ($property->getValidators() as $validator) { + if ( + $validator->getValidator() instanceof TypeCheckInterface && + in_array(strtolower(static::TYPE), $validator->getValidator()->getTypes(), true) + ) { + return; + } + } + $property->addValidator( new TypeCheckValidator(static::TYPE, $property, $this->isImplicitNullAllowed($property)), 2, diff --git a/src/PropertyProcessor/Property/AbstractValueProcessor.php b/src/PropertyProcessor/Property/AbstractValueProcessor.php index 4b4d03ff..b8ff9ce9 100644 --- a/src/PropertyProcessor/Property/AbstractValueProcessor.php +++ b/src/PropertyProcessor/Property/AbstractValueProcessor.php @@ -11,7 +11,6 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\PropertyProcessor\Filter\FilterProcessor; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use ReflectionException; @@ -26,12 +25,12 @@ abstract class AbstractValueProcessor extends AbstractPropertyProcessor * AbstractValueProcessor constructor. */ public function __construct( - PropertyMetaDataCollection $propertyMetaDataCollection, SchemaProcessor $schemaProcessor, Schema $schema, + bool $required = false, private readonly string $type = '', ) { - parent::__construct($propertyMetaDataCollection, $schemaProcessor, $schema); + parent::__construct($schemaProcessor, $schema, $required); } /** @@ -50,7 +49,7 @@ public function process(string $propertyName, JsonSchema $propertySchema): Prope $propertySchema, $json['description'] ?? '', )) - ->setRequired($this->propertyMetaDataCollection->isAttributeRequired($propertyName)) + ->setRequired($this->required) ->setReadOnly( (isset($json['readOnly']) && $json['readOnly'] === true) || $this->schemaProcessor->getGeneratorConfiguration()->isImmutable(), diff --git a/src/PropertyProcessor/Property/ArrayProcessor.php b/src/PropertyProcessor/Property/ArrayProcessor.php index 972f59f0..f519f74a 100644 --- a/src/PropertyProcessor/Property/ArrayProcessor.php +++ b/src/PropertyProcessor/Property/ArrayProcessor.php @@ -16,15 +16,12 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Property\PropertyType; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; -use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Validator\AdditionalItemsValidator; use PHPModelGenerator\Model\Validator\ArrayItemValidator; use PHPModelGenerator\Model\Validator\ArrayTupleValidator; use PHPModelGenerator\Model\Validator\PropertyTemplateValidator; use PHPModelGenerator\Model\Validator\PropertyValidator; -use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; use PHPModelGenerator\PropertyProcessor\Decorator\Property\DefaultArrayToEmptyArrayDecorator; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\Utils\RenderHelper; @@ -239,7 +236,6 @@ private function addContainsValidation(PropertyInterface $property, JsonSchema $ // an item of the array behaves like a nested property to add item-level validation $nestedProperty = (new PropertyFactory(new PropertyProcessorFactory())) ->create( - new PropertyMetaDataCollection(), $this->schemaProcessor, $this->schema, "item of array {$property->getName()}", diff --git a/src/PropertyProcessor/Property/BaseProcessor.php b/src/PropertyProcessor/Property/BaseProcessor.php index 8368e69b..1ed779b5 100644 --- a/src/PropertyProcessor/Property/BaseProcessor.php +++ b/src/PropertyProcessor/Property/BaseProcessor.php @@ -15,6 +15,7 @@ use PHPModelGenerator\Model\Property\Property; use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Property\PropertyType; +use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; @@ -27,9 +28,11 @@ use PHPModelGenerator\Model\Validator\PropertyNamesValidator; use PHPModelGenerator\Model\Validator\PropertyTemplateValidator; use PHPModelGenerator\Model\Validator\PropertyValidator; +use PHPModelGenerator\Model\Validator\PropertyDependencyValidator; +use PHPModelGenerator\Model\Validator\SchemaDependencyValidator; use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor; use PHPModelGenerator\PropertyProcessor\ComposedValue\ComposedPropertiesInterface; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; +use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; @@ -244,10 +247,6 @@ protected function addPropertiesToSchema(JsonSchema $propertySchema): void $json = $propertySchema->getJson(); $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); - $propertyMetaDataCollection = new PropertyMetaDataCollection( - $json['required'] ?? [], - $json['dependencies'] ?? [], - ); $json['properties'] ??= []; // setup empty properties for required properties which aren't defined in the properties section of the schema @@ -278,14 +277,79 @@ protected function addPropertiesToSchema(JsonSchema $propertySchema): void continue; } + $required = in_array($propertyName, $json['required'] ?? [], true); + $dependencies = $json['dependencies'][$propertyName] ?? null; + $property = $propertyFactory->create( + $this->schemaProcessor, + $this->schema, + (string) $propertyName, + $propertySchema->withJson( + $dependencies !== null + ? $propertyStructure + ['_dependencies' => $dependencies] + : $propertyStructure, + ), + $required, + ); + + if ($dependencies !== null) { + $this->addDependencyValidator($property, $dependencies); + } + + $this->schema->addProperty($property); + } + } + + /** + * @throws SchemaException + */ + private function addDependencyValidator(PropertyInterface $property, array $dependencies): void + { + // check if we have a simple list of properties which must be present if the current property is present + $propertyDependency = true; + + array_walk( + $dependencies, + static function ($dependency, $index) use (&$propertyDependency): void { + $propertyDependency = $propertyDependency && is_int($index) && is_string($dependency); + }, + ); + + if ($propertyDependency) { + $property->addValidator(new PropertyDependencyValidator($property, $dependencies)); + + return; + } + + if (!isset($dependencies['type'])) { + $dependencies['type'] = 'object'; + } + + $dependencySchema = $this->schemaProcessor->processSchema( + new JsonSchema($this->schema->getJsonSchema()->getFile(), $dependencies), + $this->schema->getClassPath(), + "{$this->schema->getClassName()}_{$property->getName()}_Dependency", + $this->schema->getSchemaDictionary(), + ); + + $property->addValidator(new SchemaDependencyValidator($this->schemaProcessor, $property, $dependencySchema)); + $this->schema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($dependencySchema)); + + $this->transferDependentPropertiesToBaseSchema($dependencySchema); + } + + /** + * Transfer all properties from $dependencySchema to the base schema of the current property + */ + private function transferDependentPropertiesToBaseSchema(Schema $dependencySchema): void + { + foreach ($dependencySchema->getProperties() as $property) { $this->schema->addProperty( - $propertyFactory->create( - $propertyMetaDataCollection, - $this->schemaProcessor, - $this->schema, - (string) $propertyName, - $propertySchema->withJson($propertyStructure), - ) + // validators and types must not be transferred as any value is acceptable for the property if the + // property defining the dependency isn't present + (clone $property) + ->setRequired(false) + ->setType(null) + ->filterValidators(static fn(): bool => false), ); } } diff --git a/src/PropertyProcessor/Property/ConstProcessor.php b/src/PropertyProcessor/Property/ConstProcessor.php index dbd9b3a2..d71b60c6 100644 --- a/src/PropertyProcessor/Property/ConstProcessor.php +++ b/src/PropertyProcessor/Property/ConstProcessor.php @@ -34,7 +34,7 @@ public function process(string $propertyName, JsonSchema $propertySchema): Prope $json['description'] ?? '', ); - $property->setRequired($this->propertyMetaDataCollection->isAttributeRequired($propertyName)); + $property->setRequired($this->required); $check = match (true) { $property->isRequired() diff --git a/src/PropertyProcessor/Property/MultiTypeProcessor.php b/src/PropertyProcessor/Property/MultiTypeProcessor.php index 56ad1cdb..b30f7d54 100644 --- a/src/PropertyProcessor/Property/MultiTypeProcessor.php +++ b/src/PropertyProcessor/Property/MultiTypeProcessor.php @@ -13,7 +13,6 @@ use PHPModelGenerator\Model\Validator\TypeCheckInterface; use PHPModelGenerator\PropertyProcessor\Decorator\Property\PropertyTransferDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorInterface; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -41,18 +40,18 @@ class MultiTypeProcessor extends AbstractValueProcessor public function __construct( PropertyProcessorFactory $propertyProcessorFactory, array $types, - PropertyMetaDataCollection $propertyMetaDataCollection, SchemaProcessor $schemaProcessor, Schema $schema, + bool $required = false, ) { - parent::__construct($propertyMetaDataCollection, $schemaProcessor, $schema); + parent::__construct($schemaProcessor, $schema, $required); foreach ($types as $type) { $this->propertyProcessors[$type] = $propertyProcessorFactory->getProcessor( $type, - $propertyMetaDataCollection, $schemaProcessor, $schema, + $required, ); } } diff --git a/src/PropertyProcessor/Property/ReferenceProcessor.php b/src/PropertyProcessor/Property/ReferenceProcessor.php index bcc73029..907d9b59 100644 --- a/src/PropertyProcessor/Property/ReferenceProcessor.php +++ b/src/PropertyProcessor/Property/ReferenceProcessor.php @@ -61,7 +61,12 @@ public function process(string $propertyName, JsonSchema $propertySchema): Prope } } - return $definition->resolveReference($propertyName, $path, $this->propertyMetaDataCollection); + return $definition->resolveReference( + $propertyName, + $path, + $this->required, + $propertySchema->getJson()['_dependencies'] ?? null, + ); } } catch (Exception $exception) { throw new SchemaException( diff --git a/src/PropertyProcessor/PropertyFactory.php b/src/PropertyProcessor/PropertyFactory.php index 1407e68a..e326e6d8 100644 --- a/src/PropertyProcessor/PropertyFactory.php +++ b/src/PropertyProcessor/PropertyFactory.php @@ -4,6 +4,8 @@ namespace PHPModelGenerator\PropertyProcessor; +use PHPModelGenerator\Draft\Draft; +use PHPModelGenerator\Draft\DraftFactoryInterface; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; @@ -17,6 +19,9 @@ */ class PropertyFactory { + /** @var Draft[] Keyed by draft class name */ + private array $draftCache = []; + public function __construct(protected ProcessorFactoryInterface $processorFactory) {} @@ -26,11 +31,11 @@ public function __construct(protected ProcessorFactoryInterface $processorFactor * @throws SchemaException */ public function create( - PropertyMetaDataCollection $propertyMetaDataCollection, SchemaProcessor $schemaProcessor, Schema $schema, string $propertyName, JsonSchema $propertySchema, + bool $required = false, ): PropertyInterface { $json = $propertySchema->getJson(); @@ -45,13 +50,65 @@ public function create( : 'reference'; } - return $this->processorFactory + $resolvedType = $json['type'] ?? 'any'; + + $property = $this->processorFactory ->getProcessor( - $json['type'] ?? 'any', - $propertyMetaDataCollection, + $resolvedType, $schemaProcessor, $schema, + $required, ) ->process($propertyName, $propertySchema); + + if (!is_array($resolvedType)) { + $this->applyDraftModifiers($schemaProcessor, $schema, $property, $propertySchema); + } + + return $property; + } + + /** + * Run all Draft modifiers for the property's type(s) on the given property. + * + * @throws SchemaException + */ + private function applyDraftModifiers( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + $configDraft = $schemaProcessor->getGeneratorConfiguration()->getDraft(); + + $draft = $configDraft instanceof DraftFactoryInterface + ? $configDraft->getDraftForSchema($propertySchema) + : $configDraft; + + $type = $propertySchema->getJson()['type'] ?? 'any'; + + $builtDraft = $this->draftCache[$draft::class] ??= $draft->getDefinition()->build(); + + // Types not declared in the draft are internal routing signals (e.g. 'allOf', 'base', + // 'reference'). They have no draft modifiers to apply. + if ($type !== 'any' && !$builtDraft->hasType($type)) { + return; + } + + // For untyped properties ('any'), only run universal modifiers (the 'any' entry itself). + // getCoveredTypes('any') returns all types — that would incorrectly apply type-specific + // modifiers (e.g. TypeCheckModifier) to properties that carry no type constraint. + $coveredTypes = $type === 'any' + ? array_filter( + $builtDraft->getCoveredTypes('any'), + static fn($t) => $t->getType() === 'any', + ) + : $builtDraft->getCoveredTypes($type); + + foreach ($coveredTypes as $coveredType) { + foreach ($coveredType->getModifiers() as $modifier) { + $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); + } + } } } diff --git a/src/PropertyProcessor/PropertyMetaDataCollection.php b/src/PropertyProcessor/PropertyMetaDataCollection.php deleted file mode 100644 index 727bb795..00000000 --- a/src/PropertyProcessor/PropertyMetaDataCollection.php +++ /dev/null @@ -1,51 +0,0 @@ -requiredAttributes); - } - - /** - * Get the dependencies for the requested attribute - */ - public function getAttributeDependencies(string $attribute): ?array - { - return $this->dependencies[$attribute] ?? null; - } - - public function getHash(string $attribute): string - { - return md5(json_encode([ - $this->getAttributeDependencies($attribute), - $this->isAttributeRequired($attribute), - ])); - } -} diff --git a/src/PropertyProcessor/PropertyProcessorFactory.php b/src/PropertyProcessor/PropertyProcessorFactory.php index 9b7f5f32..ed798cc0 100644 --- a/src/PropertyProcessor/PropertyProcessorFactory.php +++ b/src/PropertyProcessor/PropertyProcessorFactory.php @@ -17,27 +17,22 @@ class PropertyProcessorFactory implements ProcessorFactoryInterface { /** - * @param string|array $type + * @param string|array $type * * @throws SchemaException */ public function getProcessor( $type, - PropertyMetaDataCollection $propertyMetaDataCollection, SchemaProcessor $schemaProcessor, Schema $schema, + bool $required = false, ): PropertyProcessorInterface { if (is_string($type)) { - return $this->getSingleTypePropertyProcessor( - $type, - $propertyMetaDataCollection, - $schemaProcessor, - $schema, - ); + return $this->getSingleTypePropertyProcessor($type, $schemaProcessor, $schema, $required); } if (is_array($type)) { - return new MultiTypeProcessor($this, $type, $propertyMetaDataCollection, $schemaProcessor, $schema); + return new MultiTypeProcessor($this, $type, $schemaProcessor, $schema, $required); } throw new SchemaException( @@ -54,9 +49,9 @@ public function getProcessor( */ protected function getSingleTypePropertyProcessor( string $type, - PropertyMetaDataCollection $propertyMetaDataCollection, SchemaProcessor $schemaProcessor, Schema $schema, + bool $required = false, ): PropertyProcessorInterface { $processor = '\\PHPModelGenerator\\PropertyProcessor\\Property\\' . ucfirst(strtolower($type)) . 'Processor'; if (!class_exists($processor)) { @@ -69,6 +64,6 @@ protected function getSingleTypePropertyProcessor( ); } - return new $processor($propertyMetaDataCollection, $schemaProcessor, $schema); + return new $processor($schemaProcessor, $schema, $required); } } diff --git a/src/SchemaProcessor/SchemaProcessor.php b/src/SchemaProcessor/SchemaProcessor.php index 18bc3a1c..bed38fee 100644 --- a/src/SchemaProcessor/SchemaProcessor.php +++ b/src/SchemaProcessor/SchemaProcessor.php @@ -17,7 +17,6 @@ use PHPModelGenerator\PropertyProcessor\Decorator\Property\ObjectInstantiationDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProvider\SchemaProviderInterface; @@ -174,7 +173,6 @@ protected function generateModel( $json['type'] = 'base'; (new PropertyFactory(new PropertyProcessorFactory()))->create( - new PropertyMetaDataCollection($jsonSchema->getJson()['required'] ?? []), $this, $schema, $className, diff --git a/src/Utils/TypeConverter.php b/src/Utils/TypeConverter.php index 1dd4dfbd..c09c77b8 100644 --- a/src/Utils/TypeConverter.php +++ b/src/Utils/TypeConverter.php @@ -15,4 +15,13 @@ public static function gettypeToInternal(string $type): string 'NULL' => 'null', ][$type] ?? $type; } + + public static function jsonSchemaToPhp(string $type): string + { + return [ + 'integer' => 'int', + 'number' => 'float', + 'boolean' => 'bool', + ][$type] ?? $type; + } } diff --git a/tests/Draft/Modifier/DefaultValueModifierTest.php b/tests/Draft/Modifier/DefaultValueModifierTest.php new file mode 100644 index 00000000..1c5ee7db --- /dev/null +++ b/tests/Draft/Modifier/DefaultValueModifierTest.php @@ -0,0 +1,121 @@ +schemaProcessor = new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + new GeneratorConfiguration(), + new RenderQueue(), + ); + + $this->schema = new Schema('', '', '', new JsonSchema('', [])); + } + + private function modifier(): DefaultValueModifier + { + return new DefaultValueModifier(); + } + + public function testSetsDefaultValueWhenPresent(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'string', 'default' => 'hello']); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame("'hello'", $property->getDefaultValue()); + } + + public function testDoesNothingWhenNoDefaultPresent(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'string']); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertNull($property->getDefaultValue()); + } + + public function testThrowsForInvalidDefaultType(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessage('Invalid type for default value of property name'); + + $jsonSchema = new JsonSchema('test.json', ['type' => 'string', 'default' => 42]); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + } + + public function testCoercesIntegerDefaultToFloatForNumberType(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'number', 'default' => 5]); + $property = new Property('price', new PropertyType('float'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame('5.0', $property->getDefaultValue()); + } + + public function testThrowsForNonNumericDefaultOnNumberType(): void + { + $this->expectException(SchemaException::class); + + $jsonSchema = new JsonSchema('test.json', ['type' => 'number', 'default' => 'not-a-number']); + $property = new Property('price', new PropertyType('float'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + } + + public function testIntegerDefaultValue(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'integer', 'default' => 42]); + $property = new Property('count', new PropertyType('integer'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame('42', $property->getDefaultValue()); + } + + public function testBooleanDefaultValue(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'boolean', 'default' => true]); + $property = new Property('flag', new PropertyType('boolean'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame('true', $property->getDefaultValue()); + } + + public function testSetsDefaultValueWhenNoTypeInSchema(): void + { + $jsonSchema = new JsonSchema('', ['default' => 'anything']); + $property = new Property('val', new PropertyType('string'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame("'anything'", $property->getDefaultValue()); + } +} diff --git a/tests/Draft/Modifier/TypeCheckModifierTest.php b/tests/Draft/Modifier/TypeCheckModifierTest.php new file mode 100644 index 00000000..f7d941b2 --- /dev/null +++ b/tests/Draft/Modifier/TypeCheckModifierTest.php @@ -0,0 +1,172 @@ +schemaProcessor = new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + new GeneratorConfiguration(), + new RenderQueue(), + ); + + $this->schema = new Schema('', '', '', new JsonSchema('', [])); + } + + public function testAddsTypeCheckValidatorForType(): void + { + $property = new Property('name', new PropertyType('string'), new JsonSchema('', ['type' => 'string'])); + $property->setRequired(true); + + $modifier = new TypeCheckModifier('string'); + $modifier->modify($this->schemaProcessor, $this->schema, $property, new JsonSchema('', ['type' => 'string'])); + + $typeCheckValidators = array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + ); + + $this->assertCount(1, $typeCheckValidators); + $this->assertContains('string', reset($typeCheckValidators)->getValidator()->getTypes()); + } + + public function testDoesNotAddDuplicateTypeCheckValidator(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'string']); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + $property->setRequired(true); + + // Add a TypeCheckValidator manually first + $property->addValidator(new TypeCheckValidator('string', $property, false), 2); + + $modifier = new TypeCheckModifier('string'); + $modifier->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $typeCheckValidators = array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + ); + + $this->assertCount(1, $typeCheckValidators); + } + + public function testDoesNotSkipWhenExistingValidatorIsForDifferentType(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'integer']); + $property = new Property('name', new PropertyType('int'), $jsonSchema); + $property->setRequired(true); + + // Add a TypeCheckValidator for a different type + $property->addValidator(new TypeCheckValidator('string', $property, false), 2); + + $modifier = new TypeCheckModifier('int'); + $modifier->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $typeCheckValidators = array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + ); + + $this->assertCount(2, $typeCheckValidators); + } + + // --- Type auto-registration --- + + #[DataProvider('typeCheckAutoRegistrationProvider')] + public function testTypeAutoRegistersTypeCheckModifier(string $jsonType, string $phpType): void + { + $type = new Type($jsonType); + + $modifiers = $type->getModifiers(); + $this->assertCount(1, $modifiers); + $this->assertInstanceOf(TypeCheckModifier::class, $modifiers[0]); + + // Verify the modifier adds a validator for the correct PHP type + $property = new Property('x', new PropertyType($phpType), new JsonSchema('', ['type' => $jsonType])); + $property->setRequired(true); + $schema = new Schema('', '', '', new JsonSchema('', [])); + $schemaProcessor = new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + new GeneratorConfiguration(), + new RenderQueue(), + ); + + $modifiers[0]->modify($schemaProcessor, $schema, $property, new JsonSchema('', ['type' => $jsonType])); + + $typeCheckValidators = array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + ); + $this->assertCount(1, $typeCheckValidators); + $this->assertContains($phpType, reset($typeCheckValidators)->getValidator()->getTypes()); + } + + public static function typeCheckAutoRegistrationProvider(): array + { + return [ + 'array' => ['array', 'array'], + 'string' => ['string', 'string'], + 'integer' => ['integer', 'int'], + 'number' => ['number', 'float'], + 'boolean' => ['boolean', 'bool'], + 'null' => ['null', 'null'], + ]; + } + + public function testTypeWithTypeCheckFalseRegistersNoModifiers(): void + { + $type = new Type('object', false); + $this->assertEmpty($type->getModifiers()); + } + + public function testAllowsImplicitNullForOptionalProperty(): void + { + $config = (new GeneratorConfiguration())->setImplicitNull(true); + $schemaProcessor = new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + $config, + new RenderQueue(), + ); + + $jsonSchema = new JsonSchema('', ['type' => 'string']); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + $property->setRequired(false); + + $modifier = new TypeCheckModifier('string'); + $modifier->modify($schemaProcessor, $this->schema, $property, $jsonSchema); + + $validators = array_values(array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + )); + + $this->assertCount(1, $validators); + // The check should allow $value === null for an optional property with implicit null enabled + $this->assertStringContainsString('null', $validators[0]->getValidator()->getCheck()); + } +} diff --git a/tests/Objects/ReferencePropertyTest.php b/tests/Objects/ReferencePropertyTest.php index 0a0acb7a..e4b70f7e 100644 --- a/tests/Objects/ReferencePropertyTest.php +++ b/tests/Objects/ReferencePropertyTest.php @@ -10,10 +10,10 @@ use PHPModelGenerator\Exception\RenderException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\GeneratorConfiguration; -use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use stdClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; /** * Class ReferencePropertyTest @@ -434,6 +434,7 @@ public static function nestedReferenceProvider(): array } #[DataProvider('nonResolvableExternalReferenceProvider')] + #[WithoutErrorHandler] public function testNonResolvableExternalReference(string $id, string $reference): void { $this->expectException(SchemaException::class); diff --git a/tests/PropertyProcessor/PropertyProcessorFactoryTest.php b/tests/PropertyProcessor/PropertyProcessorFactoryTest.php index dde9a9a2..e8ae6210 100644 --- a/tests/PropertyProcessor/PropertyProcessorFactoryTest.php +++ b/tests/PropertyProcessor/PropertyProcessorFactoryTest.php @@ -15,7 +15,6 @@ use PHPModelGenerator\PropertyProcessor\Property\NumberProcessor; use PHPModelGenerator\PropertyProcessor\Property\ObjectProcessor; use PHPModelGenerator\PropertyProcessor\Property\StringProcessor; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\RenderQueue; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -40,7 +39,6 @@ public function testGetPropertyProcessor(string $type, string $expectedClass): v $propertyProcessor = $propertyProcessorFactory->getProcessor( $type, - new PropertyMetaDataCollection(), new SchemaProcessor( new RecursiveDirectoryProvider(__DIR__), '', @@ -81,7 +79,6 @@ public function testGetInvalidPropertyProcessorThrowsAnException(): void $propertyProcessorFactory->getProcessor( 'Hello', - new PropertyMetaDataCollection(), new SchemaProcessor( new RecursiveDirectoryProvider(__DIR__), '', From e8053b4818eb555c93f40495057819fd03db5b32 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 27 Mar 2026 01:30:26 +0100 Subject: [PATCH 3/9] docs --- .claude/settings.local.json | 4 +++- docs/requirements.txt | Bin 1128 -> 124 bytes docs/source/conf.py | 2 +- docs/source/gettingStarted.rst | 30 ------------------------------ 4 files changed, 4 insertions(+), 32 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0094988b..e3ae7e5a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -31,7 +31,9 @@ "Bash(composer show:*)", "Bash(php -r \":*)", "Bash(gh pr:*)", - "Bash(xargs wc:*)" + "Bash(xargs wc:*)", + "Bash(tee /tmp/phpunit-output.txt)", + "Read(//tmp/**)" ] } } diff --git a/docs/requirements.txt b/docs/requirements.txt index 9d515cb3343a0d03487cc2ab2f67b554a8e35a9f..7497fd90f7c4df20a995e7ee27b7700da9914519 100644 GIT binary patch delta 62 zcmaFCQ6nA9P{5GEkjaq8P{ClwV9Q|6pvPdqpu=FpU^#g@lQJhr%m^elIi5+A2gEY~ J^2`{x7yx~@3RnOD literal 1128 zcmbVM+iJp45ZvcNKP8fjy*>1$kA*_PFGyodqDhQN?Cs;*&g`COOrcMNFLRWF6Ca18fRc?T9xfDbhi z$)VE968v6y5l}kK9lnMB7K~UboFT_tHK?!zn#dm21(@A|5y%xdmb3t?cGb*ztJ;Ms zW^nFM`-yL=2dc~rRDT3g%Nn(zqO1-yU!?dGkXAPEZ)AbrtBHzr*#JqviX~IM%sFuq zmhf3VfK{lj(TR0IFZOAS1`O)7nzjaenEENFM<=KAwxlhzcgV?Z#||O%hi*BCcbCoX z`H0k*^PBEOsdMIDRGQef!&C>~&;h60LZ3b{)Ec;@T3_P#aR1c!?KrLH&c6P$Hb;&M z-b_Aqw)_4o`5Je~*TBxt%e-^bI^VNHQcjNl4SX%UqYVDm$B=@r$0olAo14N}M~9T5 JzRGKTJ3r5atUmw% diff --git a/docs/source/conf.py b/docs/source/conf.py index 098ac05f..f5c84852 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -58,7 +58,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. diff --git a/docs/source/gettingStarted.rst b/docs/source/gettingStarted.rst index 4302f772..1bd33e41 100644 --- a/docs/source/gettingStarted.rst +++ b/docs/source/gettingStarted.rst @@ -325,36 +325,6 @@ the ``$schema`` keyword of each schema file to select the appropriate draft auto the keyword is absent or unrecognised, it falls back to JSON Schema Draft 7 behaviour, so schemas with different ``$schema`` declarations in the same generation run can use different drafts. -To pin all schemas to a specific draft: - -.. code-block:: php - - use PHPModelGenerator\Draft\Draft_07; - - (new GeneratorConfiguration()) - ->setDraft(new Draft_07()); - -To use a custom draft factory that selects the draft based on your own logic: - -.. code-block:: php - - use PHPModelGenerator\Draft\DraftFactoryInterface; - use PHPModelGenerator\Draft\DraftInterface; - use PHPModelGenerator\Draft\Draft_07; - use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; - - class MyDraftFactory implements DraftFactoryInterface - { - public function getDraftForSchema(JsonSchema $jsonSchema): DraftInterface - { - // select draft based on schema content - return new Draft_07(); - } - } - - (new GeneratorConfiguration()) - ->setDraft(new MyDraftFactory()); - Available draft classes: ============= ================================ From 3bec25153615c0608b1a265903dd29688963e844 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 27 Mar 2026 16:24:08 +0100 Subject: [PATCH 4/9] Implement Phase 4: migrate scalar/array/universal keyword validators to validator factories Introduce AbstractValidatorFactory hierarchy (SimplePropertyValidatorFactory, SimpleBaseValidatorFactory) and migrate all keyword-specific validator generation out of the legacy processors into dedicated factory classes registered on Draft_07. Migrated types: string (pattern, minLength, maxLength, format), integer/number (minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf), array (items, minItems, maxItems, uniqueItems, contains), universal (enum, filter). Also adds DefaultArrayToEmptyArrayModifier for the array default-empty behaviour. Legacy processor generateValidators overrides for string, numeric, and array types are removed entirely. AbstractNumericProcessor is now empty (flattened into IntegerProcessor/NumberProcessor directly extending AbstractTypedValueProcessor). PropertyFactory exposes applyTypeModifiers/applyUniversalModifiers as public methods; MultiTypeProcessor uses these directly instead of a skipUniversalModifiers flag, eliminating the 'type-only' sentinel. FilterValidator.validateFilterCompatibilityWithBaseType now derives the effective type from getNestedSchema() for object properties instead of relying on a set/restore workaround in FilterValidatorFactory. --- src/Draft/Draft_07.php | 50 +++- src/Draft/Element/Type.php | 9 + .../DefaultArrayToEmptyArrayModifier.php | 42 +++ .../Factory/AbstractValidatorFactory.php | 17 ++ .../Factory/Any/EnumValidatorFactory.php | 96 +++++++ .../Factory/Any/FilterValidatorFactory.php | 41 +++ .../Arrays/ContainsValidatorFactory.php | 58 +++++ .../Factory/Arrays/ItemsValidatorFactory.php | 120 +++++++++ .../Arrays/MaxItemsValidatorFactory.php | 29 +++ .../Arrays/MinItemsValidatorFactory.php | 29 +++ .../Arrays/UniqueItemsValidatorFactory.php | 38 +++ .../Number/AbstractRangeValidatorFactory.php | 35 +++ .../ExclusiveMaximumValidatorFactory.php | 20 ++ .../ExclusiveMinimumValidatorFactory.php | 20 ++ .../Number/MaximumValidatorFactory.php | 20 ++ .../Number/MinimumValidatorFactory.php | 20 ++ .../MultipleOfPropertyValidatorFactory.php | 41 +++ .../Factory/SimpleBaseValidatorFactory.php | 28 ++ .../SimplePropertyValidatorFactory.php | 57 ++++ .../Factory/String/FormatValidatorFactory.php | 54 ++++ .../String/MaxLengthValidatorFactory.php | 23 ++ .../MinLengthPropertyValidatorFactory.php | 29 +++ .../PatternPropertyValidatorFactory.php | 58 +++++ src/Model/Validator/FilterValidator.php | 23 +- .../PassThroughTypeCheckValidator.php | 2 +- .../Validator/PropertyNamesValidator.php | 23 +- .../Filter/FilterProcessor.php | 11 +- .../Property/AbstractNumericProcessor.php | 105 -------- .../Property/AbstractPropertyProcessor.php | 74 ------ .../Property/AbstractValueProcessor.php | 11 - .../Property/ArrayProcessor.php | 244 ------------------ .../Property/IntegerProcessor.php | 2 +- .../Property/MultiTypeProcessor.php | 18 +- .../Property/NumberProcessor.php | 2 +- .../Property/StringProcessor.php | 127 --------- src/PropertyProcessor/PropertyFactory.php | 83 +++++- tests/Basic/FilterTest.php | 9 +- 37 files changed, 1057 insertions(+), 611 deletions(-) create mode 100644 src/Draft/Modifier/DefaultArrayToEmptyArrayModifier.php create mode 100644 src/Model/Validator/Factory/AbstractValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Any/EnumValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Any/FilterValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Arrays/MaxItemsValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Arrays/MinItemsValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Arrays/UniqueItemsValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Number/AbstractRangeValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Number/ExclusiveMaximumValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Number/ExclusiveMinimumValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Number/MaximumValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Number/MinimumValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Number/MultipleOfPropertyValidatorFactory.php create mode 100644 src/Model/Validator/Factory/SimpleBaseValidatorFactory.php create mode 100644 src/Model/Validator/Factory/SimplePropertyValidatorFactory.php create mode 100644 src/Model/Validator/Factory/String/FormatValidatorFactory.php create mode 100644 src/Model/Validator/Factory/String/MaxLengthValidatorFactory.php create mode 100644 src/Model/Validator/Factory/String/MinLengthPropertyValidatorFactory.php create mode 100644 src/Model/Validator/Factory/String/PatternPropertyValidatorFactory.php diff --git a/src/Draft/Draft_07.php b/src/Draft/Draft_07.php index 92b5c133..776dba10 100644 --- a/src/Draft/Draft_07.php +++ b/src/Draft/Draft_07.php @@ -5,7 +5,24 @@ namespace PHPModelGenerator\Draft; use PHPModelGenerator\Draft\Element\Type; +use PHPModelGenerator\Draft\Modifier\DefaultArrayToEmptyArrayModifier; use PHPModelGenerator\Draft\Modifier\DefaultValueModifier; +use PHPModelGenerator\Model\Validator\Factory\Any\EnumValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Any\FilterValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Arrays\ContainsValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Arrays\ItemsValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Arrays\MaxItemsValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Arrays\MinItemsValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Arrays\UniqueItemsValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Number\ExclusiveMaximumValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Number\ExclusiveMinimumValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Number\MaximumValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Number\MinimumValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Number\MultipleOfPropertyValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\String\FormatValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\String\MaxLengthValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\String\MinLengthPropertyValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\String\PatternPropertyValidatorFactory; class Draft_07 implements DraftInterface { @@ -13,12 +30,35 @@ public function getDefinition(): DraftBuilder { return (new DraftBuilder()) ->addType(new Type('object', false)) - ->addType(new Type('array')) - ->addType(new Type('string')) - ->addType(new Type('integer')) - ->addType(new Type('number')) + ->addType((new Type('array')) + ->addValidator('items', new ItemsValidatorFactory()) + ->addValidator('minItems', new MinItemsValidatorFactory()) + ->addValidator('maxItems', new MaxItemsValidatorFactory()) + ->addValidator('uniqueItems', new UniqueItemsValidatorFactory()) + ->addValidator('contains', new ContainsValidatorFactory()) + ->addModifier(new DefaultArrayToEmptyArrayModifier())) + ->addType((new Type('string')) + ->addValidator('pattern', new PatternPropertyValidatorFactory()) + ->addValidator('minLength', new MinLengthPropertyValidatorFactory()) + ->addValidator('maxLength', new MaxLengthValidatorFactory()) + ->addValidator('format', new FormatValidatorFactory())) + ->addType((new Type('integer')) + ->addValidator('minimum', new MinimumValidatorFactory('is_int')) + ->addValidator('maximum', new MaximumValidatorFactory('is_int')) + ->addValidator('exclusiveMinimum', new ExclusiveMinimumValidatorFactory('is_int')) + ->addValidator('exclusiveMaximum', new ExclusiveMaximumValidatorFactory('is_int')) + ->addValidator('multipleOf', new MultipleOfPropertyValidatorFactory('is_int', true))) + ->addType((new Type('number')) + ->addValidator('minimum', new MinimumValidatorFactory('is_float')) + ->addValidator('maximum', new MaximumValidatorFactory('is_float')) + ->addValidator('exclusiveMinimum', new ExclusiveMinimumValidatorFactory('is_float')) + ->addValidator('exclusiveMaximum', new ExclusiveMaximumValidatorFactory('is_float')) + ->addValidator('multipleOf', new MultipleOfPropertyValidatorFactory('is_float', false))) ->addType(new Type('boolean')) ->addType(new Type('null')) - ->addType((new Type('any', false))->addModifier(new DefaultValueModifier())); + ->addType((new Type('any', false)) + ->addValidator('enum', new EnumValidatorFactory()) + ->addValidator('filter', new FilterValidatorFactory()) + ->addModifier(new DefaultValueModifier())); } } diff --git a/src/Draft/Element/Type.php b/src/Draft/Element/Type.php index 6e5056f7..fa221218 100644 --- a/src/Draft/Element/Type.php +++ b/src/Draft/Element/Type.php @@ -6,6 +6,7 @@ use PHPModelGenerator\Draft\Modifier\ModifierInterface; use PHPModelGenerator\Draft\Modifier\TypeCheckModifier; +use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory; use PHPModelGenerator\Utils\TypeConverter; class Type @@ -27,6 +28,14 @@ public function addModifier(ModifierInterface $modifier): self return $this; } + public function addValidator(string $validatorKey, AbstractValidatorFactory $factory): self + { + $factory->setKey($validatorKey); + $this->modifiers[] = $factory; + + return $this; + } + public function getType(): string { return $this->type; diff --git a/src/Draft/Modifier/DefaultArrayToEmptyArrayModifier.php b/src/Draft/Modifier/DefaultArrayToEmptyArrayModifier.php new file mode 100644 index 00000000..160fdd35 --- /dev/null +++ b/src/Draft/Modifier/DefaultArrayToEmptyArrayModifier.php @@ -0,0 +1,42 @@ +isRequired() || + !$schemaProcessor->getGeneratorConfiguration()->isDefaultArraysToEmptyArrayEnabled() + ) { + return; + } + + $property->addDecorator(new DefaultArrayToEmptyArrayDecorator()); + + if ($property->getType()) { + $property->setType( + $property->getType(), + new PropertyType($property->getType(true)->getNames(), false), + ); + } + + if (!$property->getDefaultValue()) { + $property->setDefaultValue([]); + } + } +} diff --git a/src/Model/Validator/Factory/AbstractValidatorFactory.php b/src/Model/Validator/Factory/AbstractValidatorFactory.php new file mode 100644 index 00000000..9545a705 --- /dev/null +++ b/src/Model/Validator/Factory/AbstractValidatorFactory.php @@ -0,0 +1,17 @@ +key = $key; + } +} diff --git a/src/Model/Validator/Factory/Any/EnumValidatorFactory.php b/src/Model/Validator/Factory/Any/EnumValidatorFactory.php new file mode 100644 index 00000000..8914e2dd --- /dev/null +++ b/src/Model/Validator/Factory/Any/EnumValidatorFactory.php @@ -0,0 +1,96 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $allowedValues = $json[$this->key]; + + if (empty($allowedValues)) { + throw new SchemaException( + sprintf( + "Empty enum property %s in file %s", + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + $allowedValues = array_unique($allowedValues); + + if (array_key_exists('default', $json)) { + if (!in_array($json['default'], $allowedValues, true)) { + throw new SchemaException( + sprintf( + "Invalid default value %s for enum property %s in file %s", + var_export($json['default'], true), + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + } + + // no type information provided - inherit the types from the enum values + if (!$property->getType()) { + $typesOfEnum = array_unique(array_map( + static fn($value): string => TypeConverter::gettypeToInternal(gettype($value)), + $allowedValues, + )); + + if (count($typesOfEnum) === 1) { + $property->setType(new PropertyType($typesOfEnum[0])); + } else { + $hasNull = in_array('null', $typesOfEnum, true); + $nonNullTypes = array_values(array_filter( + $typesOfEnum, + static fn(string $t): bool => $t !== 'null', + )); + + if ($nonNullTypes) { + $propertyType = new PropertyType($nonNullTypes, $hasNull ? true : null); + $property->setType($propertyType, $propertyType); + } + } + + $property->addTypeHintDecorator(new TypeHintDecorator($typesOfEnum)); + } + + $implicitNullAllowed = $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() + && !$property->isRequired(); + + if ($implicitNullAllowed && !in_array(null, $allowedValues, true)) { + $allowedValues[] = null; + } + + $property->addValidator(new EnumValidator($property, $allowedValues), 3); + } +} diff --git a/src/Model/Validator/Factory/Any/FilterValidatorFactory.php b/src/Model/Validator/Factory/Any/FilterValidatorFactory.php new file mode 100644 index 00000000..e5326157 --- /dev/null +++ b/src/Model/Validator/Factory/Any/FilterValidatorFactory.php @@ -0,0 +1,41 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + (new FilterProcessor())->process( + $property, + $json[$this->key], + $schemaProcessor->getGeneratorConfiguration(), + $schema, + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php new file mode 100644 index 00000000..ca0bb529 --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php @@ -0,0 +1,58 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $nestedProperty = (new PropertyFactory(new PropertyProcessorFactory())) + ->create( + $schemaProcessor, + $schema, + "item of array {$property->getName()}", + $propertySchema->withJson($json[$this->key]), + ); + + $property->addValidator( + new PropertyTemplateValidator( + $property, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ArrayContains.phptpl', + [ + 'property' => $nestedProperty, + 'schema' => $schema, + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + ], + ContainsException::class, + ), + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php new file mode 100644 index 00000000..031ea2e4 --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php @@ -0,0 +1,120 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $itemsSchema = $json[$this->key]; + + // tuple validation: items is a sequential array + if ( + is_array($itemsSchema) && + array_keys($itemsSchema) === range(0, count($itemsSchema) - 1) + ) { + $this->addTupleValidator($schemaProcessor, $schema, $property, $propertySchema, $itemsSchema); + + return; + } + + $property->addValidator( + new ArrayItemValidator( + $schemaProcessor, + $schema, + $propertySchema->withJson($itemsSchema), + $property, + ), + ); + } + + /** + * @throws SchemaException + */ + private function addTupleValidator( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + array $itemsSchema, + ): void { + $json = $propertySchema->getJson(); + + if (isset($json['additionalItems']) && $json['additionalItems'] !== true) { + $this->addAdditionalItemsValidator($schemaProcessor, $schema, $property, $propertySchema, $itemsSchema); + } + + $property->addValidator( + new ArrayTupleValidator( + $schemaProcessor, + $schema, + $propertySchema->withJson($itemsSchema), + $property->getName(), + ), + ); + } + + /** + * @throws SchemaException + */ + private function addAdditionalItemsValidator( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + array $itemsSchema, + ): void { + $json = $propertySchema->getJson(); + + if (!is_bool($json['additionalItems'])) { + $property->addValidator( + new AdditionalItemsValidator( + $schemaProcessor, + $schema, + $propertySchema, + $property->getName(), + ), + ); + + return; + } + + $expectedAmount = count($itemsSchema); + + $property->addValidator( + new PropertyValidator( + $property, + '($amount = count($value)) > ' . $expectedAmount, + AdditionalTupleItemsException::class, + [$expectedAmount, '&$amount'], + ), + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/MaxItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/MaxItemsValidatorFactory.php new file mode 100644 index 00000000..4d7b940e --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/MaxItemsValidatorFactory.php @@ -0,0 +1,29 @@ += 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + "is_array(\$value) && count(\$value) > $value", + MaxItemsException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/MinItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/MinItemsValidatorFactory.php new file mode 100644 index 00000000..188ae00b --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/MinItemsValidatorFactory.php @@ -0,0 +1,29 @@ += 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + "is_array(\$value) && count(\$value) < $value", + MinItemsException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/UniqueItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/UniqueItemsValidatorFactory.php new file mode 100644 index 00000000..7a955751 --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/UniqueItemsValidatorFactory.php @@ -0,0 +1,38 @@ +getJson(); + + if (!isset($json[$this->key]) || $json[$this->key] !== true) { + return; + } + + $property->addValidator( + new PropertyTemplateValidator( + $property, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ArrayUnique.phptpl', + [], + UniqueItemsException::class, + ), + ); + } +} diff --git a/src/Model/Validator/Factory/Number/AbstractRangeValidatorFactory.php b/src/Model/Validator/Factory/Number/AbstractRangeValidatorFactory.php new file mode 100644 index 00000000..377b018f --- /dev/null +++ b/src/Model/Validator/Factory/Number/AbstractRangeValidatorFactory.php @@ -0,0 +1,35 @@ +typeCheck}(\$value) && \$value {$this->getOperator()} $value", + $this->getExceptionClass(), + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Number/ExclusiveMaximumValidatorFactory.php b/src/Model/Validator/Factory/Number/ExclusiveMaximumValidatorFactory.php new file mode 100644 index 00000000..aaca9540 --- /dev/null +++ b/src/Model/Validator/Factory/Number/ExclusiveMaximumValidatorFactory.php @@ -0,0 +1,20 @@ +='; + } + + protected function getExceptionClass(): string + { + return ExclusiveMaximumException::class; + } +} diff --git a/src/Model/Validator/Factory/Number/ExclusiveMinimumValidatorFactory.php b/src/Model/Validator/Factory/Number/ExclusiveMinimumValidatorFactory.php new file mode 100644 index 00000000..cf9c1679 --- /dev/null +++ b/src/Model/Validator/Factory/Number/ExclusiveMinimumValidatorFactory.php @@ -0,0 +1,20 @@ +'; + } + + protected function getExceptionClass(): string + { + return MaximumException::class; + } +} diff --git a/src/Model/Validator/Factory/Number/MinimumValidatorFactory.php b/src/Model/Validator/Factory/Number/MinimumValidatorFactory.php new file mode 100644 index 00000000..5926bf2f --- /dev/null +++ b/src/Model/Validator/Factory/Number/MinimumValidatorFactory.php @@ -0,0 +1,20 @@ +typeCheck}(\$value) && \$value != 0"; + } elseif ($this->isInteger) { + $check = "{$this->typeCheck}(\$value) && \$value % $value != 0"; + } else { + $check = "{$this->typeCheck}(\$value) && fmod(\$value, $value) != 0"; + } + + return new PropertyValidator( + $property, + $check, + MultipleOfException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php b/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php new file mode 100644 index 00000000..5f448964 --- /dev/null +++ b/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php @@ -0,0 +1,28 @@ +hasValidValue($property, $propertySchema)) { + return; + } + + $schema->addBaseValidator( + $this->getValidator($property, $propertySchema->getJson()[$this->key]), + ); + } +} diff --git a/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php b/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php new file mode 100644 index 00000000..29a71d79 --- /dev/null +++ b/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php @@ -0,0 +1,57 @@ +hasValidValue($property, $propertySchema)) { + return; + } + + $property->addValidator( + $this->getValidator($property, $propertySchema->getJson()[$this->key]), + ); + } + + protected function hasValidValue(PropertyInterface $property, JsonSchema $propertySchema): bool + { + $json = $propertySchema->getJson(); + + if (!isset($json[$this->key])) { + return false; + } + + if (!$this->isValueValid($json[$this->key])) { + throw new SchemaException( + sprintf( + "Invalid %s %s for property '%s' in file %s", + $this->key, + str_replace("\n", '', var_export($json[$this->key], true)), + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + return true; + } + + abstract protected function isValueValid(mixed $value): bool; + + abstract protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface; +} diff --git a/src/Model/Validator/Factory/String/FormatValidatorFactory.php b/src/Model/Validator/Factory/String/FormatValidatorFactory.php new file mode 100644 index 00000000..725066fd --- /dev/null +++ b/src/Model/Validator/Factory/String/FormatValidatorFactory.php @@ -0,0 +1,54 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $format = $json[$this->key]; + $formatValidator = $schemaProcessor->getGeneratorConfiguration()->getFormat($format); + + if (!$formatValidator) { + throw new SchemaException( + sprintf( + 'Unsupported format %s for property %s in file %s', + $format, + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + $property->addValidator( + new FormatValidator( + $property, + $formatValidator, + [$format], + ), + ); + } +} diff --git a/src/Model/Validator/Factory/String/MaxLengthValidatorFactory.php b/src/Model/Validator/Factory/String/MaxLengthValidatorFactory.php new file mode 100644 index 00000000..4caf0ba0 --- /dev/null +++ b/src/Model/Validator/Factory/String/MaxLengthValidatorFactory.php @@ -0,0 +1,23 @@ + $value", + MaxLengthException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/String/MinLengthPropertyValidatorFactory.php b/src/Model/Validator/Factory/String/MinLengthPropertyValidatorFactory.php new file mode 100644 index 00000000..98e2d54b --- /dev/null +++ b/src/Model/Validator/Factory/String/MinLengthPropertyValidatorFactory.php @@ -0,0 +1,29 @@ += 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + "is_string(\$value) && mb_strlen(\$value) < $value", + MinLengthException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/String/PatternPropertyValidatorFactory.php b/src/Model/Validator/Factory/String/PatternPropertyValidatorFactory.php new file mode 100644 index 00000000..e1fa3b6f --- /dev/null +++ b/src/Model/Validator/Factory/String/PatternPropertyValidatorFactory.php @@ -0,0 +1,58 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $pattern = (string) $json[$this->key]; + $escapedPattern = addcslashes($pattern, '/'); + + if (@preg_match("/$escapedPattern/", '') === false) { + throw new SchemaException( + sprintf( + "Invalid pattern '%s' for property '%s' in file %s", + $pattern, + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + $encodedPattern = base64_encode("/$escapedPattern/"); + + $property->addValidator( + new PropertyValidator( + $property, + "is_string(\$value) && !preg_match(base64_decode('$encodedPattern'), \$value)", + PatternException::class, + [$pattern], + ), + ); + } +} diff --git a/src/Model/Validator/FilterValidator.php b/src/Model/Validator/FilterValidator.php index 23b6fe65..6959871a 100644 --- a/src/Model/Validator/FilterValidator.php +++ b/src/Model/Validator/FilterValidator.php @@ -121,24 +121,25 @@ public function addTransformedCheck(TransformingFilterInterface $filter, Propert */ private function validateFilterCompatibilityWithBaseType(FilterInterface $filter, PropertyInterface $property): void { - if (empty($filter->getAcceptedTypes()) || !$property->getType()) { + if (empty($filter->getAcceptedTypes()) || (!$property->getType() && !$property->getNestedSchema())) { return; } - $typeNames = $property->getType()->getNames(); - if ( - ( - !empty($typeNames) && - !empty(array_diff($typeNames, $this->mapDataTypes($filter->getAcceptedTypes()))) - ) || ( - $property->getType()->isNullable() && !in_array('null', $filter->getAcceptedTypes()) - ) - ) { + $typeNames = $property->getNestedSchema() !== null ? ['object'] : $property->getType()->getNames(); + $incompatibleTypes = !empty($typeNames) + ? array_diff($typeNames, $this->mapDataTypes($filter->getAcceptedTypes())) + : []; + + if ($property->getType()?->isNullable() && !in_array('null', $filter->getAcceptedTypes())) { + $incompatibleTypes[] = 'null'; + } + + if (!empty($incompatibleTypes)) { throw new SchemaException( sprintf( 'Filter %s is not compatible with property type %s for property %s in file %s', $filter->getToken(), - implode('|', $typeNames), + implode('|', array_merge($typeNames, $property->getType()?->isNullable() ? ['null'] : [])), $property->getName(), $property->getJsonSchema()->getFile(), ) diff --git a/src/Model/Validator/PassThroughTypeCheckValidator.php b/src/Model/Validator/PassThroughTypeCheckValidator.php index 4f4655d3..0676e764 100644 --- a/src/Model/Validator/PassThroughTypeCheckValidator.php +++ b/src/Model/Validator/PassThroughTypeCheckValidator.php @@ -24,7 +24,7 @@ class PassThroughTypeCheckValidator extends PropertyValidator implements TypeChe public function __construct( ReflectionType $passThroughType, PropertyInterface $property, - TypeCheckValidator $typeCheckValidator, + TypeCheckValidator|MultiTypeCheckValidator $typeCheckValidator, ) { $this->types = array_merge($typeCheckValidator->getTypes(), [$passThroughType->getName()]); diff --git a/src/Model/Validator/PropertyNamesValidator.php b/src/Model/Validator/PropertyNamesValidator.php index 8f3b5d99..b5fd76b2 100644 --- a/src/Model/Validator/PropertyNamesValidator.php +++ b/src/Model/Validator/PropertyNamesValidator.php @@ -10,8 +10,8 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator; -use PHPModelGenerator\PropertyProcessor\Property\ConstProcessor; -use PHPModelGenerator\PropertyProcessor\Property\StringProcessor; +use PHPModelGenerator\PropertyProcessor\PropertyFactory; +use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -34,16 +34,21 @@ public function __construct( ) { $this->isResolved = true; - $processor = array_key_exists('const', $propertiesNames->getJson()) - ? ConstProcessor::class - : StringProcessor::class; - - if ($processor === ConstProcessor::class && gettype($propertiesNames->getJson()['const']) !== 'string') { + if ( + array_key_exists('const', $propertiesNames->getJson()) && + gettype($propertiesNames->getJson()['const']) !== 'string' + ) { throw new SchemaException("Invalid const property name in file {$propertiesNames->getFile()}"); } - $nameValidationProperty = (new $processor($schemaProcessor, $schema)) - ->process('property name', $propertiesNames) + // Property names are always strings; ensure the schema declares the type so that + // string-specific validators (minLength, maxLength, pattern) are applied. + $propertiesNamesAsString = array_key_exists('type', $propertiesNames->getJson()) + ? $propertiesNames + : $propertiesNames->withJson(['type' => 'string'] + $propertiesNames->getJson()); + + $nameValidationProperty = (new PropertyFactory(new PropertyProcessorFactory())) + ->create($schemaProcessor, $schema, 'property name', $propertiesNamesAsString) // the property name validator doesn't need type checks or required checks so simply filter them out ->filterValidators(static fn(Validator $validator): bool => !is_a($validator->getValidator(), RequiredPropertyValidator::class) && diff --git a/src/PropertyProcessor/Filter/FilterProcessor.php b/src/PropertyProcessor/Filter/FilterProcessor.php index 53fdf81f..dc5d985a 100644 --- a/src/PropertyProcessor/Filter/FilterProcessor.php +++ b/src/PropertyProcessor/Filter/FilterProcessor.php @@ -15,6 +15,7 @@ use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Validator\EnumValidator; use PHPModelGenerator\Model\Validator\FilterValidator; +use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator; use PHPModelGenerator\Model\Validator\PassThroughTypeCheckValidator; use PHPModelGenerator\Model\Validator\PropertyValidator; use PHPModelGenerator\Model\Validator\ReflectionTypeCheckValidator; @@ -210,7 +211,10 @@ private function extendTypeCheckValidatorToAllowTransformedValue( $typeCheckValidator = null; $property->filterValidators(static function (Validator $validator) use (&$typeCheckValidator): bool { - if (is_a($validator->getValidator(), TypeCheckValidator::class)) { + if ( + is_a($validator->getValidator(), TypeCheckValidator::class) || + is_a($validator->getValidator(), MultiTypeCheckValidator::class) + ) { $typeCheckValidator = $validator->getValidator(); return false; } @@ -218,7 +222,10 @@ private function extendTypeCheckValidatorToAllowTransformedValue( return true; }); - if ($typeCheckValidator instanceof TypeCheckValidator) { + if ( + $typeCheckValidator instanceof TypeCheckValidator + || $typeCheckValidator instanceof MultiTypeCheckValidator + ) { // add a combined validator which checks for the transformed value or the original type of the property as a // replacement for the removed TypeCheckValidator $property->addValidator( diff --git a/src/PropertyProcessor/Property/AbstractNumericProcessor.php b/src/PropertyProcessor/Property/AbstractNumericProcessor.php index 0824dcf4..fc756a22 100644 --- a/src/PropertyProcessor/Property/AbstractNumericProcessor.php +++ b/src/PropertyProcessor/Property/AbstractNumericProcessor.php @@ -4,15 +4,6 @@ namespace PHPModelGenerator\PropertyProcessor\Property; -use PHPModelGenerator\Exception\Number\ExclusiveMaximumException; -use PHPModelGenerator\Exception\Number\ExclusiveMinimumException; -use PHPModelGenerator\Exception\Number\MaximumException; -use PHPModelGenerator\Exception\Number\MinimumException; -use PHPModelGenerator\Exception\Number\MultipleOfException; -use PHPModelGenerator\Model\Property\PropertyInterface; -use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; -use PHPModelGenerator\Model\Validator\PropertyValidator; - /** * Class AbstractNumericProcessor * @@ -20,100 +11,4 @@ */ abstract class AbstractNumericProcessor extends AbstractTypedValueProcessor { - protected const JSON_FIELD_MINIMUM = 'minimum'; - protected const JSON_FIELD_MAXIMUM = 'maximum'; - - protected const JSON_FIELD_MINIMUM_EXCLUSIVE = 'exclusiveMinimum'; - protected const JSON_FIELD_MAXIMUM_EXCLUSIVE = 'exclusiveMaximum'; - - protected const JSON_FIELD_MULTIPLE = 'multipleOf'; - - /** - * @inheritdoc - */ - protected function generateValidators(PropertyInterface $property, JsonSchema $propertySchema): void - { - parent::generateValidators($property, $propertySchema); - - $this->addRangeValidator($property, $propertySchema, self::JSON_FIELD_MINIMUM, '<', MinimumException::class); - $this->addRangeValidator($property, $propertySchema, self::JSON_FIELD_MAXIMUM, '>', MaximumException::class); - - $this->addRangeValidator( - $property, - $propertySchema, - self::JSON_FIELD_MINIMUM_EXCLUSIVE, - '<=', - ExclusiveMinimumException::class, - ); - - $this->addRangeValidator( - $property, - $propertySchema, - self::JSON_FIELD_MAXIMUM_EXCLUSIVE, - '>=', - ExclusiveMaximumException::class, - ); - - $this->addMultipleOfValidator($property, $propertySchema); - } - - /** - * Adds a range validator to the property - * - * @param PropertyInterface $property The property which shall be validated - * @param JsonSchema $propertySchema The schema for the property - * @param string $field Which field of the property data provides the validation value - * @param string $check The check to execute (eg. '<', '>') - * @param string $exceptionClass The exception class for the validation - */ - protected function addRangeValidator( - PropertyInterface $property, - JsonSchema $propertySchema, - string $field, - string $check, - string $exceptionClass, - ): void { - $json = $propertySchema->getJson(); - - if (!isset($json[$field])) { - return; - } - - $property->addValidator( - new PropertyValidator( - $property, - $this->getTypeCheck() . "\$value $check {$json[$field]}", - $exceptionClass, - [$json[$field]], - ) - ); - } - - /** - * Adds a multiple of validator to the property - */ - protected function addMultipleOfValidator(PropertyInterface $property, JsonSchema $propertySchema) - { - $json = $propertySchema->getJson(); - - if (!isset($json[self::JSON_FIELD_MULTIPLE])) { - return; - } - - $property->addValidator( - new PropertyValidator( - $property, - // type unsafe comparison to be compatible with int and float - $json[self::JSON_FIELD_MULTIPLE] == 0 - ? $this->getTypeCheck() . '$value != 0' - : ( - static::TYPE === 'int' - ? $this->getTypeCheck() . "\$value % {$json[self::JSON_FIELD_MULTIPLE]} != 0" - : $this->getTypeCheck() . "fmod(\$value, {$json[self::JSON_FIELD_MULTIPLE]}) != 0" - ), - MultipleOfException::class, - [$json[self::JSON_FIELD_MULTIPLE]], - ), - ); - } } diff --git a/src/PropertyProcessor/Property/AbstractPropertyProcessor.php b/src/PropertyProcessor/Property/AbstractPropertyProcessor.php index b4634e28..bd4aae48 100644 --- a/src/PropertyProcessor/Property/AbstractPropertyProcessor.php +++ b/src/PropertyProcessor/Property/AbstractPropertyProcessor.php @@ -7,18 +7,14 @@ use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Property\BaseProperty; use PHPModelGenerator\Model\Property\PropertyInterface; -use PHPModelGenerator\Model\Property\PropertyType; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; -use PHPModelGenerator\Model\Validator\EnumValidator; use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; use PHPModelGenerator\PropertyProcessor\ComposedValueProcessorFactory; -use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintTransferDecorator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorInterface; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; -use PHPModelGenerator\Utils\TypeConverter; /** * Class AbstractPropertyProcessor @@ -44,79 +40,9 @@ protected function generateValidators(PropertyInterface $property, JsonSchema $p $property->addValidator(new RequiredPropertyValidator($property), 1); } - if (isset($propertySchema->getJson()['enum'])) { - $this->addEnumValidator($property, $propertySchema->getJson()['enum']); - } - $this->addComposedValueValidator($property, $propertySchema); } - /** - * Add a validator to a property which validates the value against a list of allowed values - * - * @throws SchemaException - */ - protected function addEnumValidator(PropertyInterface $property, array $allowedValues): void - { - if (empty($allowedValues)) { - throw new SchemaException( - sprintf( - "Empty enum property %s in file %s", - $property->getName(), - $property->getJsonSchema()->getFile(), - ) - ); - } - - $allowedValues = array_unique($allowedValues); - - if (array_key_exists('default', $property->getJsonSchema()->getJson())) { - if (!in_array($property->getJsonSchema()->getJson()['default'], $allowedValues, true)) { - throw new SchemaException( - sprintf( - "Invalid default value %s for enum property %s in file %s", - var_export($property->getJsonSchema()->getJson()['default'], true), - $property->getName(), - $property->getJsonSchema()->getFile(), - ), - ); - } - } - - // no type information provided - inherit the types from the enum values - if (!$property->getType()) { - $typesOfEnum = array_unique(array_map( - static fn($value): string => TypeConverter::gettypeToInternal(gettype($value)), - $allowedValues, - )); - - if (count($typesOfEnum) === 1) { - $property->setType(new PropertyType($typesOfEnum[0])); - } else { - // Multiple types: set a union PropertyType so the native PHP type hint path can - // emit e.g. string|int instead of falling back to no hint at all. - // 'NULL' must be expressed as nullable=true rather than kept as a type name. - $hasNull = in_array('null', $typesOfEnum, true); - $nonNullTypes = array_values(array_filter( - $typesOfEnum, - static fn(string $t): bool => $t !== 'null', - )); - - if ($nonNullTypes) { - $propertyType = new PropertyType($nonNullTypes, $hasNull ? true : null); - $property->setType($propertyType, $propertyType); - } - } - $property->addTypeHintDecorator(new TypeHintDecorator($typesOfEnum)); - } - - if ($this->isImplicitNullAllowed($property) && !in_array(null, $allowedValues, true)) { - $allowedValues[] = null; - } - - $property->addValidator(new EnumValidator($property, $allowedValues), 3); - } - /** * @throws SchemaException */ diff --git a/src/PropertyProcessor/Property/AbstractValueProcessor.php b/src/PropertyProcessor/Property/AbstractValueProcessor.php index b8ff9ce9..03b55d29 100644 --- a/src/PropertyProcessor/Property/AbstractValueProcessor.php +++ b/src/PropertyProcessor/Property/AbstractValueProcessor.php @@ -10,9 +10,7 @@ use PHPModelGenerator\Model\Property\PropertyType; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; -use PHPModelGenerator\PropertyProcessor\Filter\FilterProcessor; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; -use ReflectionException; /** * Class AbstractScalarValueProcessor @@ -57,15 +55,6 @@ public function process(string $propertyName, JsonSchema $propertySchema): Prope $this->generateValidators($property, $propertySchema); - if (isset($json['filter'])) { - (new FilterProcessor())->process( - $property, - $json['filter'], - $this->schemaProcessor->getGeneratorConfiguration(), - $this->schema, - ); - } - return $property; } } diff --git a/src/PropertyProcessor/Property/ArrayProcessor.php b/src/PropertyProcessor/Property/ArrayProcessor.php index f519f74a..4c9cb7ec 100644 --- a/src/PropertyProcessor/Property/ArrayProcessor.php +++ b/src/PropertyProcessor/Property/ArrayProcessor.php @@ -4,28 +4,6 @@ namespace PHPModelGenerator\PropertyProcessor\Property; -use PHPMicroTemplate\Exception\FileSystemException; -use PHPMicroTemplate\Exception\SyntaxErrorException; -use PHPMicroTemplate\Exception\UndefinedSymbolException; -use PHPModelGenerator\Exception\Arrays\AdditionalTupleItemsException; -use PHPModelGenerator\Exception\Arrays\ContainsException; -use PHPModelGenerator\Exception\Arrays\MaxItemsException; -use PHPModelGenerator\Exception\Arrays\MinItemsException; -use PHPModelGenerator\Exception\Arrays\UniqueItemsException; -use PHPModelGenerator\Exception\SchemaException; -use PHPModelGenerator\Model\Property\PropertyInterface; -use PHPModelGenerator\Model\Property\PropertyType; -use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; -use PHPModelGenerator\Model\Validator\AdditionalItemsValidator; -use PHPModelGenerator\Model\Validator\ArrayItemValidator; -use PHPModelGenerator\Model\Validator\ArrayTupleValidator; -use PHPModelGenerator\Model\Validator\PropertyTemplateValidator; -use PHPModelGenerator\Model\Validator\PropertyValidator; -use PHPModelGenerator\PropertyProcessor\Decorator\Property\DefaultArrayToEmptyArrayDecorator; -use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; -use PHPModelGenerator\Utils\RenderHelper; - /** * Class ArrayProcessor * @@ -34,226 +12,4 @@ class ArrayProcessor extends AbstractTypedValueProcessor { protected const string TYPE = 'array'; - - private const string JSON_FIELD_MIN_ITEMS = 'minItems'; - private const string JSON_FIELD_MAX_ITEMS = 'maxItems'; - private const string JSON_FIELD_ITEMS = 'items'; - private const string JSON_FIELD_CONTAINS = 'contains'; - - /** - * @throws FileSystemException - * @throws SchemaException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - protected function generateValidators(PropertyInterface $property, JsonSchema $propertySchema): void - { - parent::generateValidators($property, $propertySchema); - - $this->addLengthValidation($property, $propertySchema); - $this->addUniqueItemsValidation($property, $propertySchema); - $this->addItemsValidation($property, $propertySchema); - $this->addContainsValidation($property, $propertySchema); - - if ( - !$property->isRequired() && - $this->schemaProcessor->getGeneratorConfiguration()->isDefaultArraysToEmptyArrayEnabled() - ) { - $property->addDecorator(new DefaultArrayToEmptyArrayDecorator()); - - if ($property->getType()) { - $property->setType( - $property->getType(), - new PropertyType($property->getType(true)->getNames(), false), - ); - } - - if (!$property->getDefaultValue()) { - $property->setDefaultValue([]); - } - } - } - - /** - * Add the vaidation for the allowed amount of items in the array - */ - private function addLengthValidation(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (isset($json[self::JSON_FIELD_MIN_ITEMS])) { - $property->addValidator( - new PropertyValidator( - $property, - $this->getTypeCheck() . "count(\$value) < {$json[self::JSON_FIELD_MIN_ITEMS]}", - MinItemsException::class, - [$json[self::JSON_FIELD_MIN_ITEMS]], - ) - ); - } - - if (isset($json[self::JSON_FIELD_MAX_ITEMS])) { - $property->addValidator( - new PropertyValidator( - $property, - $this->getTypeCheck() . "count(\$value) > {$json[self::JSON_FIELD_MAX_ITEMS]}", - MaxItemsException::class, - [$json[self::JSON_FIELD_MAX_ITEMS]], - ) - ); - } - } - - /** - * Add the validator to check if the items inside an array are unique - */ - private function addUniqueItemsValidation(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!isset($json['uniqueItems']) || $json['uniqueItems'] !== true) { - return; - } - - $property->addValidator( - new PropertyTemplateValidator( - $property, - DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ArrayUnique.phptpl', - [], - UniqueItemsException::class, - ) - ); - } - - /** - * Add the validator to check for constraints required for each item - * - * @throws FileSystemException - * @throws SchemaException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - private function addItemsValidation(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!isset($json[self::JSON_FIELD_ITEMS])) { - return; - } - - // check if the items require a tuple validation - if ( - is_array($json[self::JSON_FIELD_ITEMS]) && - array_keys($json[self::JSON_FIELD_ITEMS]) === range(0, count($json[self::JSON_FIELD_ITEMS]) - 1) - ) { - $this->addTupleValidator($property, $propertySchema); - - return; - } - - $property->addValidator( - new ArrayItemValidator( - $this->schemaProcessor, - $this->schema, - $propertySchema->withJson($json[self::JSON_FIELD_ITEMS]), - $property, - ) - ); - } - - /** - * Add the validator to check a tuple validation for each item of the array - * - * @throws SchemaException - * @throws FileSystemException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - private function addTupleValidator(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (isset($json['additionalItems']) && $json['additionalItems'] !== true) { - $this->addAdditionalItemsValidator($property, $propertySchema); - } - - $property->addValidator( - new ArrayTupleValidator( - $this->schemaProcessor, - $this->schema, - $propertySchema->withJson($json[self::JSON_FIELD_ITEMS]), - $property->getName(), - ) - ); - } - - /** - * @throws FileSystemException - * @throws SchemaException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - private function addAdditionalItemsValidator(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!is_bool($json['additionalItems'])) { - $property->addValidator( - new AdditionalItemsValidator( - $this->schemaProcessor, - $this->schema, - $propertySchema, - $property->getName(), - ) - ); - - return; - } - - $expectedAmount = count($json[self::JSON_FIELD_ITEMS]); - - $property->addValidator( - new PropertyValidator( - $property, - '($amount = count($value)) > ' . $expectedAmount, - AdditionalTupleItemsException::class, - [$expectedAmount, '&$amount'], - ) - ); - } - - /** - * Add the validator to check for constraints required for at least one item - * - * @throws SchemaException - */ - private function addContainsValidation(PropertyInterface $property, JsonSchema $propertySchema): void - { - if (!isset($propertySchema->getJson()[self::JSON_FIELD_CONTAINS])) { - return; - } - - // an item of the array behaves like a nested property to add item-level validation - $nestedProperty = (new PropertyFactory(new PropertyProcessorFactory())) - ->create( - $this->schemaProcessor, - $this->schema, - "item of array {$property->getName()}", - $propertySchema->withJson($propertySchema->getJson()[self::JSON_FIELD_CONTAINS]), - ); - - $property->addValidator( - new PropertyTemplateValidator( - $property, - DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ArrayContains.phptpl', - [ - 'property' => $nestedProperty, - 'schema' => $this->schema, - 'viewHelper' => new RenderHelper($this->schemaProcessor->getGeneratorConfiguration()), - 'generatorConfiguration' => $this->schemaProcessor->getGeneratorConfiguration(), - ], - ContainsException::class, - ) - ); - } } diff --git a/src/PropertyProcessor/Property/IntegerProcessor.php b/src/PropertyProcessor/Property/IntegerProcessor.php index a0f5da7e..6291bb82 100644 --- a/src/PropertyProcessor/Property/IntegerProcessor.php +++ b/src/PropertyProcessor/Property/IntegerProcessor.php @@ -9,7 +9,7 @@ * * @package PHPModelGenerator\PropertyProcessor\Property */ -class IntegerProcessor extends AbstractNumericProcessor +class IntegerProcessor extends AbstractTypedValueProcessor { protected const string TYPE = 'int'; } diff --git a/src/PropertyProcessor/Property/MultiTypeProcessor.php b/src/PropertyProcessor/Property/MultiTypeProcessor.php index b30f7d54..1153a221 100644 --- a/src/PropertyProcessor/Property/MultiTypeProcessor.php +++ b/src/PropertyProcessor/Property/MultiTypeProcessor.php @@ -13,6 +13,7 @@ use PHPModelGenerator\Model\Validator\TypeCheckInterface; use PHPModelGenerator\PropertyProcessor\Decorator\Property\PropertyTransferDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; +use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorInterface; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -38,7 +39,7 @@ class MultiTypeProcessor extends AbstractValueProcessor * @throws SchemaException */ public function __construct( - PropertyProcessorFactory $propertyProcessorFactory, + private readonly PropertyProcessorFactory $propertyProcessorFactory, array $types, SchemaProcessor $schemaProcessor, Schema $schema, @@ -47,7 +48,7 @@ public function __construct( parent::__construct($schemaProcessor, $schema, $required); foreach ($types as $type) { - $this->propertyProcessors[$type] = $propertyProcessorFactory->getProcessor( + $this->propertyProcessors[$type] = $this->propertyProcessorFactory->getProcessor( $type, $schemaProcessor, $schema, @@ -172,10 +173,19 @@ protected function processSubProperties( unset($json['default']); } + $subPropertyFactory = new PropertyFactory($this->propertyProcessorFactory); + foreach ($this->propertyProcessors as $type => $propertyProcessor) { $json['type'] = $type; - - $subProperty = $propertyProcessor->process($propertyName, $propertySchema->withJson($json)); + $subSchema = $propertySchema->withJson($json); + + $subProperty = $propertyProcessor->process($propertyName, $subSchema); + $subPropertyFactory->applyTypeModifiers( + $this->schemaProcessor, + $this->schema, + $subProperty, + $subSchema, + ); $subProperty->onResolve(function () use ($property, $subProperty): void { $this->transferValidators($subProperty, $property); diff --git a/src/PropertyProcessor/Property/NumberProcessor.php b/src/PropertyProcessor/Property/NumberProcessor.php index d401e1fd..f5116548 100644 --- a/src/PropertyProcessor/Property/NumberProcessor.php +++ b/src/PropertyProcessor/Property/NumberProcessor.php @@ -13,7 +13,7 @@ * * @package PHPModelGenerator\PropertyProcessor\Property */ -class NumberProcessor extends AbstractNumericProcessor +class NumberProcessor extends AbstractTypedValueProcessor { protected const string TYPE = 'float'; diff --git a/src/PropertyProcessor/Property/StringProcessor.php b/src/PropertyProcessor/Property/StringProcessor.php index 8b3bb7b9..7ae4db10 100644 --- a/src/PropertyProcessor/Property/StringProcessor.php +++ b/src/PropertyProcessor/Property/StringProcessor.php @@ -4,15 +4,6 @@ namespace PHPModelGenerator\PropertyProcessor\Property; -use PHPModelGenerator\Exception\SchemaException; -use PHPModelGenerator\Exception\String\MaxLengthException; -use PHPModelGenerator\Exception\String\MinLengthException; -use PHPModelGenerator\Exception\String\PatternException; -use PHPModelGenerator\Model\Property\PropertyInterface; -use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; -use PHPModelGenerator\Model\Validator\FormatValidator; -use PHPModelGenerator\Model\Validator\PropertyValidator; - /** * Class StringProcessor * @@ -21,122 +12,4 @@ class StringProcessor extends AbstractTypedValueProcessor { protected const string TYPE = 'string'; - - protected const JSON_FIELD_PATTERN = 'pattern'; - protected const JSON_FIELD_FORMAT = 'format'; - protected const JSON_FIELD_MIN_LENGTH = 'minLength'; - protected const JSON_FIELD_MAX_LENGTH = 'maxLength'; - - /** - * @throws SchemaException - */ - protected function generateValidators(PropertyInterface $property, JsonSchema $propertySchema): void - { - parent::generateValidators($property, $propertySchema); - - $this->addPatternValidator($property, $propertySchema); - $this->addLengthValidator($property, $propertySchema); - $this->addFormatValidator($property, $propertySchema); - } - - /** - * Add a regex pattern validator - * - * @throws SchemaException - */ - protected function addPatternValidator(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!isset($json[static::JSON_FIELD_PATTERN])) { - return; - } - - $escapedPattern = addcslashes((string) $json[static::JSON_FIELD_PATTERN], '/'); - - if (@preg_match("/$escapedPattern/", '') === false) { - throw new SchemaException( - sprintf( - "Invalid pattern '%s' for property '%s' in file %s", - $json[static::JSON_FIELD_PATTERN], - $property->getName(), - $propertySchema->getFile(), - ) - ); - } - - $encodedPattern = base64_encode("/$escapedPattern/"); - - $property->addValidator( - new PropertyValidator( - $property, - $this->getTypeCheck() . "!preg_match(base64_decode('$encodedPattern'), \$value)", - PatternException::class, - [$json[static::JSON_FIELD_PATTERN]], - ) - ); - } - - /** - * Add min and max length validator - */ - protected function addLengthValidator(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (isset($json[static::JSON_FIELD_MIN_LENGTH])) { - $property->addValidator( - new PropertyValidator( - $property, - $this->getTypeCheck() . "mb_strlen(\$value) < {$json[static::JSON_FIELD_MIN_LENGTH]}", - MinLengthException::class, - [$json[static::JSON_FIELD_MIN_LENGTH]], - ) - ); - } - - if (isset($json[static::JSON_FIELD_MAX_LENGTH])) { - $property->addValidator( - new PropertyValidator( - $property, - $this->getTypeCheck() . "mb_strlen(\$value) > {$json[static::JSON_FIELD_MAX_LENGTH]}", - MaxLengthException::class, - [$json[static::JSON_FIELD_MAX_LENGTH]], - ) - ); - } - } - - /** - * @throws SchemaException - */ - protected function addFormatValidator(PropertyInterface $property, JsonSchema $propertySchema): void - { - if (!isset($propertySchema->getJson()[self::JSON_FIELD_FORMAT])) { - return; - } - - $formatValidator = $this->schemaProcessor - ->getGeneratorConfiguration() - ->getFormat($propertySchema->getJson()[self::JSON_FIELD_FORMAT]); - - if (!$formatValidator) { - throw new SchemaException( - sprintf( - 'Unsupported format %s for property %s in file %s', - $propertySchema->getJson()[self::JSON_FIELD_FORMAT], - $property->getName(), - $propertySchema->getFile(), - ) - ); - } - - $property->addValidator( - new FormatValidator( - $property, - $formatValidator, - [$propertySchema->getJson()[self::JSON_FIELD_FORMAT]], - ) - ); - } } diff --git a/src/PropertyProcessor/PropertyFactory.php b/src/PropertyProcessor/PropertyFactory.php index e326e6d8..49dcb7be 100644 --- a/src/PropertyProcessor/PropertyFactory.php +++ b/src/PropertyProcessor/PropertyFactory.php @@ -26,7 +26,7 @@ public function __construct(protected ProcessorFactoryInterface $processorFactor {} /** - * Create a property + * Create a property, applying all applicable Draft modifiers. * * @throws SchemaException */ @@ -61,7 +61,11 @@ public function create( ) ->process($propertyName, $propertySchema); - if (!is_array($resolvedType)) { + if (is_array($resolvedType)) { + // For multi-type properties the type-specific modifiers run per sub-property inside + // MultiTypeProcessor via applyTypeModifiers(). Only the universal modifiers run here. + $this->applyUniversalModifiers($schemaProcessor, $schema, $property, $propertySchema); + } else { $this->applyDraftModifiers($schemaProcessor, $schema, $property, $propertySchema); } @@ -69,25 +73,73 @@ public function create( } /** - * Run all Draft modifiers for the property's type(s) on the given property. + * Run only the type-specific Draft modifiers (no universal 'any' modifiers) for the given + * property. Used by MultiTypeProcessor to apply per-type modifiers to each sub-property + * without double-applying universal modifiers that run separately on the main property. * * @throws SchemaException */ - private function applyDraftModifiers( + public function applyTypeModifiers( SchemaProcessor $schemaProcessor, Schema $schema, PropertyInterface $property, JsonSchema $propertySchema, ): void { - $configDraft = $schemaProcessor->getGeneratorConfiguration()->getDraft(); + $type = $propertySchema->getJson()['type'] ?? 'any'; + $builtDraft = $this->resolveBuiltDraft($schemaProcessor, $propertySchema); - $draft = $configDraft instanceof DraftFactoryInterface - ? $configDraft->getDraftForSchema($propertySchema) - : $configDraft; + if ($type === 'any' || !$builtDraft->hasType($type)) { + return; + } - $type = $propertySchema->getJson()['type'] ?? 'any'; + foreach ($builtDraft->getCoveredTypes($type) as $coveredType) { + if ($coveredType->getType() === 'any') { + continue; + } - $builtDraft = $this->draftCache[$draft::class] ??= $draft->getDefinition()->build(); + foreach ($coveredType->getModifiers() as $modifier) { + $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); + } + } + } + + /** + * Run only the universal ('any') Draft modifiers for the given property. + * + * @throws SchemaException + */ + public function applyUniversalModifiers( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + $builtDraft = $this->resolveBuiltDraft($schemaProcessor, $propertySchema); + + foreach ($builtDraft->getCoveredTypes('any') as $coveredType) { + if ($coveredType->getType() !== 'any') { + continue; + } + + foreach ($coveredType->getModifiers() as $modifier) { + $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); + } + } + } + + /** + * Run all Draft modifiers (type-specific and universal) for the given property. + * + * @throws SchemaException + */ + private function applyDraftModifiers( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + $type = $propertySchema->getJson()['type'] ?? 'any'; + $builtDraft = $this->resolveBuiltDraft($schemaProcessor, $propertySchema); // Types not declared in the draft are internal routing signals (e.g. 'allOf', 'base', // 'reference'). They have no draft modifiers to apply. @@ -111,4 +163,15 @@ private function applyDraftModifiers( } } } + + private function resolveBuiltDraft(SchemaProcessor $schemaProcessor, JsonSchema $propertySchema): Draft + { + $configDraft = $schemaProcessor->getGeneratorConfiguration()->getDraft(); + + $draft = $configDraft instanceof DraftFactoryInterface + ? $configDraft->getDraftForSchema($propertySchema) + : $configDraft; + + return $this->draftCache[$draft::class] ??= $draft->getDefinition()->build(); + } } diff --git a/tests/Basic/FilterTest.php b/tests/Basic/FilterTest.php index 3e5fd5b6..682d4b4b 100644 --- a/tests/Basic/FilterTest.php +++ b/tests/Basic/FilterTest.php @@ -386,9 +386,7 @@ protected function getCustomTransformingFilter( string $token = 'customTransformingFilter', array $acceptedTypes = ['string'], ): TransformingFilterInterface { - return new class ($customSerializer, $customFilter, $token, $acceptedTypes) - extends TrimFilter - implements TransformingFilterInterface + return new class ($customSerializer, $customFilter, $token, $acceptedTypes) extends TrimFilter implements TransformingFilterInterface { public function __construct( private readonly array $customSerializer, @@ -484,8 +482,7 @@ public function testFilterExceptionsAreCaught(): void $this->expectExceptionMessage(<<generateClassFromFile( 'TransformingFilter.json', @@ -799,7 +796,7 @@ public function testFilterWhichAppliesToMultiTypePropertyPartiallyThrowsAnExcept { $this->expectException(SchemaException::class); $this->expectExceptionMessage( - 'Filter trim is not compatible with property type null for property filteredProperty', + 'Filter trim is not compatible with property type string|null for property filteredProperty', ); $this->generateClassFromFile( From 997b639d41877a005c410d2d8b04dc70382997c1 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Sat, 28 Mar 2026 00:30:33 +0100 Subject: [PATCH 5/9] Implement Phase 5: eliminate MultiTypeProcessor Multi-type properties ("type": [...]) are now handled directly in PropertyFactory::createMultiTypeProperty instead of delegating to MultiTypeProcessor. Each type is processed through its legacy single-type processor and Draft type modifiers; type-check validators are collected and consolidated into a single MultiTypeCheckValidator; decorators are forwarded via PropertyTransferDecorator; universal modifiers run once on the main property after all sub-properties resolve. - Delete MultiTypeProcessor - PropertyProcessorFactory::getProcessor now only handles string types; the private getSingleTypePropertyProcessor wrapper is inlined - ProcessorFactoryInterface::getProcessor parameter narrowed to string - Type validity is checked via PropertyFactory::checkType (shared between scalar and multi-type paths) - Three invalidRecursiveMultiType test expectations updated: outer InvalidItemException property name changes from "item of array property" to "property" due to different extracted method registration order --- .../implementation-plan.md | 785 ++++++++++++++++++ .../ProcessorFactoryInterface.php | 5 +- .../Property/MultiTypeProcessor.php | 216 ----- src/PropertyProcessor/PropertyFactory.php | 192 ++++- .../PropertyProcessorFactory.php | 29 - tests/Objects/MultiTypePropertyTest.php | 8 +- 6 files changed, 972 insertions(+), 263 deletions(-) create mode 100644 .claude/topics/reworkstructure-analysis/implementation-plan.md delete mode 100644 src/PropertyProcessor/Property/MultiTypeProcessor.php diff --git a/.claude/topics/reworkstructure-analysis/implementation-plan.md b/.claude/topics/reworkstructure-analysis/implementation-plan.md new file mode 100644 index 00000000..8a868bcd --- /dev/null +++ b/.claude/topics/reworkstructure-analysis/implementation-plan.md @@ -0,0 +1,785 @@ +# Implementation Plan: Draft-Based Architecture Rework + +Based on `implementation-analysis.md` and the Q&A decisions recorded there. + +--- + +## Guiding principles + +- Every phase must leave the full test suite green before the next phase begins. +- Each phase is a standalone PR — no phase may depend on uncommitted work from another. +- The existing integration-style test suite (generate → instantiate → assert) is the primary + regression guard. Unit tests for new classes are added in the same phase that introduces them. +- "Non-breaking inside the phase" means: no public API removal until the phase that explicitly + targets that removal (the public API of the library is `ModelGenerator`, `GeneratorConfiguration`, + and the Schema/Property interfaces used by post-processors). + +--- + +## Phase 0 — `RenderJob` simplification + +**Goal**: Remove `classPath`/`className` from `RenderJob` constructor (they already live on +`Schema`). Purely internal, zero behaviour change. + +**Scope**: +- Read `RenderJob` constructor — already done: it only takes `Schema $schema`. This phase is + already complete in the current codebase. **Skip.** + +--- + +## Phase 1 — Introduce `DraftInterface`, `Draft_07`, `AutoDetectionDraft` **[DONE]** + +**Goal**: Define the Draft abstraction and wire it into `GeneratorConfiguration`. All existing +processor classes remain intact and are not yet called through the Draft. The Draft is structurally +present but the pipeline does not use it yet. + +### Implemented structure + +New files (all created and committed): +- `src/Draft/Modifier/ModifierInterface.php` — single method: + `modify(SchemaProcessor, Schema, PropertyInterface, JsonSchema): void` +- `src/Draft/Element/Type.php` — holds a type name, an optional `TypeCheckModifier` added by + default, and a list of `ModifierInterface[]`. Has `addModifier(ModifierInterface): self` and + `getModifiers(): ModifierInterface[]`. +- `src/Draft/DraftBuilder.php` — collects `Type` objects keyed by type name; + `addType(Type): self`, `getType(string): ?Type`, `build(): Draft`. +- `src/Draft/Draft.php` — value object holding `Type[]`; `getTypes(): Type[]`, + `getCoveredTypes(string|array $type): Type[]` (includes `'any'` type automatically). +- `src/Draft/DraftInterface.php` — `getDefinition(): DraftBuilder` +- `src/Draft/DraftFactoryInterface.php` — `getDraftForSchema(JsonSchema): DraftInterface` +- `src/Draft/Draft_07.php` — implements `DraftInterface`. Returns a `DraftBuilder` with all + seven JSON Schema types (`object`, `array`, `string`, `integer`, `number`, `boolean`, `null`) + plus an `'any'` pseudo-type for universal modifiers. Types currently have only + `TypeCheckModifier` (via `Type` constructor); `object` and `any` pass `false` for + `$typeCheck`. `'any'` holds `DefaultValueModifier`. +- `src/Draft/AutoDetectionDraft.php` — implements `DraftFactoryInterface`. Caches draft + instances per class. Currently always returns `Draft_07` (all schemas fall back to it). +- `src/Draft/Modifier/TypeCheckModifier.php` — implements `ModifierInterface`; adds + `TypeCheckValidator` if not already present. +- `src/Draft/Modifier/DefaultValueModifier.php` — implements `ModifierInterface`; reads + `$json['default']` and calls `$property->setDefaultValue(...)`. + +`GeneratorConfiguration` holds a `DraftFactoryInterface $draftFactory` (defaulting to +`AutoDetectionDraft`) with `getDraftFactory()` / `setDraftFactory()`. + +### Docs for Phase 1 + +- Add `docs/source/generator-configuration.rst` section describing `setDraftFactory()` and the + draft concept at a high level. + +--- + +## Phase 2 — Eliminate `PropertyMetaDataCollection` + +**Goal**: Drop `PropertyMetaDataCollection` entirely. Both pieces of data it carried +(`required` array and `dependencies` map) are already available from `Schema::getJsonSchema()` +or can be passed as a plain `bool $required` at call sites. This eliminates all save/restore +mutations of shared `Schema` state that were introduced as workarounds, including those that +the original plan deferred to Phases 7 and 8. + +### Why `PropertyMetaDataCollection` can be dropped completely + +`PropertyMetaDataCollection` carried two pieces of data: + +1. **`required` array** — always available at + `$schema->getJsonSchema()->getJson()['required']` on the parent `Schema`. For synthetic + properties (composition elements, additional/pattern/tuple properties), the required state + is a fixed call-site decision — not schema data — so it belongs as a direct `bool $required` + parameter rather than being injected via a mutable collection. + +2. **`dependencies` map** — available at + `$schema->getJsonSchema()->getJson()['dependencies']` on the parent `Schema`. Dependency + validation is only semantically correct at the point where the parent schema's JSON is + available alongside the named property being added — i.e. in + `BaseProcessor::addPropertiesToSchema`. Moving the `addDependencyValidator` call there, + reading directly from `$json['dependencies'][$propertyName]`, eliminates any need to + thread dependency data through the pipeline. + +### 2.1 — Add `bool $required = false` to `PropertyFactory::create` and processor constructors + +- `PropertyFactory::create` gains `bool $required = false` parameter. +- `ProcessorFactoryInterface::getProcessor` gains `bool $required = false` parameter. +- `PropertyProcessorFactory::getProcessor` and `getSingleTypePropertyProcessor` gain + `bool $required = false` and pass it to the processor constructor. +- `AbstractPropertyProcessor::__construct` gains `protected bool $required = false`. +- `ComposedValueProcessorFactory::getProcessor` gains `bool $required = false` and passes + it through. + +### 2.2 — Replace `isAttributeRequired` lookups + +Everywhere `isAttributeRequired($propertyName)` was called: + +- **`AbstractValueProcessor::process`** — replace with `$this->required` (constructor value). +- **`ConstProcessor::process`** — same. +- **`ReferenceProcessor::process`** — use `$this->required` when calling + `$definition->resolveReference($propertyName, $path, $this->required)`. + +For the **normal named-property path** (`BaseProcessor::addPropertiesToSchema`), compute +`$required = in_array($propertyName, $json['required'] ?? [], true)` at the call site and +pass it to `PropertyFactory::create`. + +### 2.3 — Move `addDependencyValidator` to `BaseProcessor::addPropertiesToSchema` + +- Remove the `getAttributeDependencies` call and `addDependencyValidator` invocation from + `AbstractPropertyProcessor::generateValidators` entirely. +- In `BaseProcessor::addPropertiesToSchema`, after calling `$propertyFactory->create(...)`, + check `$json['dependencies'][$propertyName] ?? null` and call + `$this->addDependencyValidator($property, $deps)` directly at that level. +- `addDependencyValidator` itself moves to `BaseProcessor` (from `AbstractPropertyProcessor`). + +### 2.4 — Update `SchemaDefinition::resolveReference` + +- Change signature: replace `PropertyMetaDataCollection $propertyMetaDataCollection` with + `bool $required` and `?array $dependencies = null`. +- Remove all save/restore of `$schema->getPropertyMetaData()`. +- Apply `$property->setRequired($required)` on the returned property. +- New cache key: `implode('-', [...$originalPath, $required ? '1' : '0', md5(json_encode($dependencies))])`. + +**Why the dependencies must be in the cache key (not the property name):** + +The original PMC hash encoded `[dependencies, required]` for the specific property — not the +property name itself. This meant two properties with different names but the same required/dependency +state (e.g. `property1` and `item of array property1` both optional with no dependencies) produced +the same cache key, which is essential for breaking recursive `$ref` cycles: the second call +(with the different property name) hits the sentinel entry for the first call and returns a proxy. + +Including `$propertyName` in the key would break recursion: each level of recursion uses a +different name (e.g. `property` → `item of array property` → `item of array item of array +property` …), so no sentinel is ever hit and the resolution loops infinitely. + +However, dependencies MUST be in the key: two properties with the same `required` state but +different dependency arrays (e.g. `property1` depends on `property3: string` while `property2` +depends on `property3: integer`) would otherwise share a cached entry and add their dependency +validators to the same underlying property object. + +**How dependencies reach `resolveReference`:** + +`BaseProcessor` injects `'_dependencies'` into the property's `JsonSchema` before calling +`PropertyFactory::create`. `ReferenceProcessor` reads `$propertySchema->getJson()['_dependencies']` +and passes it to `resolveReference`. All other call sites pass `null` (no dependencies). + +### 2.5 — Remove all PMC save/restore sites + +All five save/restore blocks introduced in Phase 2 (and noted as Phase 7/8 debt in the +original plan) are removed in this phase: + +- **`AdditionalPropertiesValidator`** — remove save/restore; pass `$required = true` to + `PropertyFactory::create` (synthetic `'additional property'` is always treated as required). +- **`PatternPropertiesValidator`** — same, always `$required = true`. +- **`ArrayTupleValidator`** — same, each tuple item is always `$required = true`. +- **`AbstractComposedValueProcessor::getCompositionProperties`** — remove save/restore; pass + `$property->isRequired()` as `bool $required` to `PropertyFactory::create`. Because + `NotProcessor` calls `$property->setRequired(true)` before `parent::generateValidators`, + this value is correctly `true` when processing `not` composition elements. +- **`IfProcessor`** — same, pass `$property->isRequired()`. + +Also: `AbstractPropertyProcessor::addComposedValueValidator` must pass `$property->isRequired()` +as `$required` to `PropertyFactory::create`. Without it, the composed value property for a +required property is constructed with `required=false`, causing `isImplicitNullAllowed` to return +`true` inside `AnyOfProcessor`/`OneOfProcessor`, which then incorrectly adds `null` to the type +hint and allows null input for required properties. + +### 2.6 — Delete `PropertyMetaDataCollection` and clean up `Schema` + +- Remove `getPropertyMetaData()` and `setPropertyMetaData()` from `Schema`. +- Remove the `$propertyMetaData` field and its initialisation from `Schema::__construct`. +- Delete `src/PropertyProcessor/PropertyMetaDataCollection.php`. + +### Tests for Phase 2 + +- `PropertyProcessorFactoryTest` — remove `PropertyMetaDataCollection` from all + `getProcessor` calls (already done in the initial Phase 2 commit); verify the test still + passes with the `bool $required` parameter added. +- The entire existing integration test suite is the regression guard. Key scenarios: + - **Issue 86 `ref.json`** — same `$ref` resolved as required vs optional; verifies the + simplified cache key (`$required` bool) correctly separates the two entries. + - **Issue 86 `schemaDependency.json`** — `$ref` properties with schema dependencies; + verifies dependency validators are correctly attached by `BaseProcessor` after + `resolveReference` returns. + - **`ComposedNotTest::ReferencedObjectSchema`** — `not` composition with a `$ref` to a + local definition; verifies that `NotProcessor`'s forced `required=true` reaches + `resolveReference` via the constructor parameter. + - **`PropertyDependencyTest`, `SchemaDependencyTest`** — all dependency validator paths. +- No new test schemas or test methods are needed; the existing tests provide complete coverage. + +### Docs for Phase 2 + +- Update any doc page that shows `PropertyFactory::create` with its parameter list. + +### Bridge-period debt resolved + +The original plan deferred the following PMC-mutation workarounds to Phases 7 and 8: + +- `AbstractComposedValueProcessor::getCompositionProperties` save/restore — **resolved here**. +- `AdditionalPropertiesValidator`, `PatternPropertiesValidator`, `ArrayTupleValidator` + save/restore — **resolved here**. +- `SchemaDefinition::resolveReference` PMC parameter — **resolved here**. + +Phase 7 and Phase 8 no longer need to address any PMC-related cleanup. + +--- + +## Phase 3 — `PropertyFactory` constructs `Property`; first modifiers land **[DONE]** + +**Goal**: Move `Property` object construction from `AbstractValueProcessor::process` into +`PropertyFactory::create`. Wire `PropertyFactory` to ask the Draft for modifiers. Introduce the +first two concrete modifier classes (`TypeCheckModifier`, `DefaultValueModifier`) and fill them +into `Draft07`. Keep all existing processor classes intact — they run _after_ the modifier +pipeline as a temporary bridge, deduplicated by the property state they observe. + +**This is the most delicate phase** because `PropertyFactory::create` currently just routes to +a processor. After this phase it both constructs `Property` and calls the processor (which +still constructs a second `Property` internally). The temporary bridge strategy: + +- `PropertyFactory::create` constructs the `Property`, sets required/readOnly, runs Draft + modifiers, then calls the legacy processor's `process()`. +- Legacy processors that call `parent::process()` (i.e. `AbstractValueProcessor`) will + construct a _second_ `Property` internally. To avoid duplicate validators, the legacy + processors are updated in Phase 4 to skip construction and receive the existing property. +- For Phase 3 only, the `AbstractTypedValueProcessor`-level TypeCheckValidator is skipped + if `TypeCheckModifier` already added it — detected by checking whether the property already + carries a `TypeCheckValidator` for that type. + +### 3.1 — Modifier: `TypeCheckModifier` + +New file `src/Draft/Modifier/TypeCheckModifier.php`: +```php +class TypeCheckModifier implements ModifierInterface { + public function __construct(private readonly string $type) {} + public function modify(...): void { + // Add TypeCheckValidator only if not already present + $property->addValidator( + new TypeCheckValidator($this->type, $property, $schemaProcessor->..isImplicitNullAllowed..($property)), + 2, + ); + } +} +``` + +Register in `Draft07`: each type gets `new TypeCheckModifier($type)` as its first modifier. +`object` type does NOT get a `TypeCheckModifier` — objects are identified by instantiation, not +a raw type check. `null` gets `new TypeCheckModifier('null')`. + +### 3.2 — Modifier: `DefaultValueModifier` + +New file `src/Draft/Modifier/DefaultValueModifier.php` — reads `$json['default']`, validates +it against the type, calls `$property->setDefaultValue(...)`. + +Register in `Draft07` after `TypeCheckModifier` for all scalar types. + +### 3.3 — `PropertyFactory::create` pipeline + +```php +public function create( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, +): PropertyInterface { + $json = $propertySchema->getJson(); + + // Resolve draft from schema's $schema keyword + $draft = $schemaProcessor->getGeneratorConfiguration()->getDraft(); + if ($draft instanceof AutoDetectionDraft) { + $draft = $draft->getDraftForSchema($propertySchema); + } + + // Construct Property (was: inside AbstractValueProcessor) + $property = new Property($propertyName, null, $propertySchema, $json['description'] ?? ''); + $property + ->setRequired($schema->getPropertyMetaData()->isAttributeRequired($propertyName)) + ->setReadOnly( + (isset($json['readOnly']) && $json['readOnly'] === true) + || $schemaProcessor->getGeneratorConfiguration()->isImmutable() + ); + + // Resolve types and run type-specific modifiers + $types = $this->resolveTypes($json); + foreach ($types as $type) { + foreach ($draft->getModifiersForType($type) as $modifier) { + $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); + } + } + + // Universal modifiers + foreach ($draft->getUniversalModifiers() as $modifier) { + $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); + } + + // Legacy bridge: route to existing processor (temporary, removed in later phases) + $property = $this->legacyProcess($json, $propertyMetaData, $schemaProcessor, $schema, $propertyName, $propertySchema, $property); + + return $property; +} +``` + +`resolveTypes`: returns `['string']` for `"type":"string"`, `['string','null']` for +`"type":["string","null"]`, `[]` for no type (untyped / `any`). + +The `legacyProcess` bridge calls the old `ProcessorFactoryInterface` path. It is removed +phase-by-phase from Phase 4 onward. + +### 3.4 — Prevent duplicate TypeCheckValidator in bridge period + +`AbstractTypedValueProcessor::generateValidators` currently always adds `TypeCheckValidator`. +Add a guard: check if the property already has a `TypeCheckValidator` for `static::TYPE` +before adding another. This is the minimal change needed to bridge Phase 3 without duplicates. + +### Tests for Phase 3 + +- New unit tests for `TypeCheckModifier` and `DefaultValueModifier` in `tests/Draft/Modifier/`. +- `tests/Objects/MultiTypePropertyTest.php` — must stay green (bridge still handles multi-type + via `MultiTypeProcessor`). +- Full integration suite must stay green. + +### Implementation notes (Phase 3) + +Implemented using Option B (legacy processor first, then modifiers on returned property). + +Key fixes required during implementation: +- `AbstractComposedValueProcessor::getCompositionProperties`: temporarily overrides the schema + PMC to reflect the parent property's `isRequired` state for each composition element. This is + essential for `NotProcessor` (which sets `isRequired=true` on the composition property to enforce + strict null checks), so that sub-properties (including referenced schemas) see the correct state. +- `AdditionalPropertiesValidator`, `PatternPropertiesValidator`, `ArrayTupleValidator`: similarly + override the schema PMC to mark validation sub-properties as required, restoring the behaviour + that Phase 1 provided via explicit PMC construction. + +--- + +## Phase 4 — Migrate scalar type keyword validators to validator factories **[DONE — commit 3bec251]** + +**Goal**: For each scalar type (`string`, `integer`, `number`, `boolean`, `null`, `array`), +move all keyword-specific validator generation from the processor `generateValidators` method +into dedicated classes. Register them in `Draft07`. Once all keywords for a type are migrated, +that processor's `generateValidators` override is deleted. + +Ordering within a type's modifier list mirrors the current `generateValidators` call order. + +### Class hierarchy for keyword-driven validators + +Most JSON Schema keywords follow a simple pattern: check whether the keyword is present in the +property's JSON, validate its value, then add a validator to the property. This phase introduces +a reusable class hierarchy for this pattern: + +- **`AbstractValidatorFactory`** (`src/Model/Validator/Factory/AbstractValidatorFactory.php`) — + implements `ModifierInterface` (already defined in Phase 1). Holds `protected $key` injected + via `setKey(string $key): void`. The `$key` is the JSON Schema keyword name (e.g. `'minLength'`), + set by `Type::addValidator` at registration time (see below). +- **`SimplePropertyValidatorFactory`** — extends `AbstractValidatorFactory`. Provides `modify()`: + reads `$property->getJsonSchema()->getJson()[$this->key]`, calls `hasValidValue()` (which also + throws `SchemaException` for invalid values), then calls abstract `getValidator()` and adds the + validator to the property. Subclasses only implement `isValueValid($value): bool` and + `getValidator(PropertyInterface, $value): PropertyValidatorInterface`. +- **`SimpleBaseValidatorFactory`** — same pattern but calls `$schema->addBaseValidator()` instead + of `$property->addValidator()`, for root-schema validators. + +**How the JSON keyword is bound to the factory**: `Type::addValidator(string $validatorKey, +AbstractValidatorFactory $factory)` is a new method added to `Type` in this phase. It calls +`$factory->setKey($validatorKey)` before appending the factory to the modifier list. This means +factories do **not** hard-code their keyword — they receive it from the registration call. The +same factory class can be reused for different keywords if the logic is identical. + +`TypeCheckModifier` also adds a validator (a `TypeCheckValidator`), but it is not keyed to a +single JSON keyword — the type comes from the `Type` registration itself. It could equally be +named `TypeCheckValidatorFactory`, but because it implements `ModifierInterface` directly without +the `$key`/`setKey` mechanism, the `*Modifier` suffix is used to signal that it is not part of the +`AbstractValidatorFactory` hierarchy. The same applies to `DefaultValueModifier` (which modifies a +property attribute rather than adding a validator). The naming rule is therefore: `*ValidatorFactory` +for classes that extend `AbstractValidatorFactory` (keyed via `setKey`); `*Modifier` for classes +that implement `ModifierInterface` directly (non-keyed). + +### Classes to create + +**String** (namespace `src/Model/Validator/Factory/String/`): +- `PatternPropertyValidatorFactory` — extends `SimplePropertyValidatorFactory`; from `StringProcessor::addPatternValidator` +- `MinLengthPropertyValidatorFactory` — extends `SimplePropertyValidatorFactory`; from `StringProcessor::addLengthValidator` (min part) +- `MaxLengthValidatorFactory` — extends `MinLengthPropertyValidatorFactory`; from `StringProcessor::addLengthValidator` (max part) +- `FormatValidatorFactory` — extends `AbstractValidatorFactory`; from `StringProcessor::addFormatValidator` + +**Integer / Number** (namespace `src/Model/Validator/Factory/Number/`): +- `MinimumValidatorFactory` — extends `SimplePropertyValidatorFactory`; from `AbstractNumericProcessor::addRangeValidator` (minimum) +- `MaximumValidatorFactory` +- `ExclusiveMinimumValidatorFactory` +- `ExclusiveMaximumValidatorFactory` +- `MultipleOfPropertyValidatorFactory` — extends `SimplePropertyValidatorFactory` + +**Array** (namespace `src/Model/Validator/Factory/Arrays/`): +- `MinItemsValidatorFactory`, `MaxItemsValidatorFactory`, `UniqueItemsValidatorFactory` — extend `SimplePropertyValidatorFactory` +- `ItemsValidatorFactory` — extends `AbstractValidatorFactory`; handles `items`, `additionalItems`, tuples +- `ContainsValidatorFactory` — extends `SimplePropertyValidatorFactory` + +**Object** (namespace `src/Model/Validator/Factory/Object/`): +- `PropertiesValidatorFactory` — extends `AbstractValidatorFactory`; from `BaseProcessor::addPropertiesToSchema` +- `PropertyNamesValidatorFactory` — extends `AbstractValidatorFactory` +- `PatternPropertiesValidatorFactory` — extends `AbstractValidatorFactory` +- `AdditionalPropertiesValidatorFactory` — extends `AbstractValidatorFactory` +- `MinPropertiesValidatorFactory`, `MaxPropertiesValidatorFactory` — extend `SimplePropertyValidatorFactory` + +**Universal** (namespace `src/Model/Validator/Factory/Any/`): +- `EnumValidatorFactory` — extends `AbstractValidatorFactory`; from `AbstractPropertyProcessor::addEnumValidator` +- `FilterValidatorFactory` — extends `AbstractValidatorFactory`; from `AbstractValueProcessor` filter call + +**Deferred** (not part of Phase 4): +- `RequiredValidatorFactory` — deferred to Phase 6/7; `RequiredPropertyValidator` is added inside + `AbstractPropertyProcessor::generateValidators` which is tightly coupled to the full processor + hierarchy, and required-state handling interacts with composition. +- `DependencyValidatorFactory` — deferred to Phase 6; dependency validation lives in + `BaseProcessor::addPropertiesToSchema` and requires `$this->schemaProcessor` + `$this->schema` + for schema-dependency processing — it belongs with the object keyword migration. +- `ReferenceValidatorFactory` — deferred to Phase 7 (`$ref` handling is tightly coupled to composition routing) +- `AllOfValidatorFactory` / other composition factories — deferred to Phase 7 (composition is the most complex migration) + +**Note on naming**: the `*ValidatorFactory` suffix is used for all classes that follow the +`AbstractValidatorFactory` hierarchy. The `*Modifier` suffix (e.g. `TypeCheckModifier`, +`DefaultValueModifier`) is reserved for objects that implement `ModifierInterface` directly and +perform property modification beyond a simple key→validator mapping. + +### Registration in `Draft_07` + +`Draft_07` registers factories on `Type` objects via `addValidator(string $key, AbstractValidatorFactory)`. +Example: + +```php +(new Type('string')) + ->addValidator('pattern', new PatternPropertyValidatorFactory()) + ->addValidator('minLength', new MinLengthPropertyValidatorFactory()) + ->addValidator('maxLength', new MaxLengthValidatorFactory()) + ->addValidator('format', new FormatValidatorFactory()), +``` + +The `Type::addValidator` method calls `$factory->setKey($key)`, injecting the JSON keyword name +into the factory before it is stored. This is the only place the keyword name is bound — factory +classes never hard-code it. + +### Migration strategy per type + +For each type, the work is: +1. Create the `AbstractValidatorFactory` subclass(es). The `$key` property is set by the + `Type` registry at registration time, not in the constructor. +2. Register in `Draft_07` via `Type::addValidator`. +3. Add deduplication guard in the legacy processor (same pattern as Phase 3: skip if + validator already present). +4. Delete the `generateValidators` override in the processor once all keywords are covered. +5. If the processor class becomes empty (only inherits from `AbstractTypedValueProcessor`), + mark it `@deprecated` — deletion happens in Phase 8. + +### Tests for Phase 4 + +- New unit tests for each validator factory class. +- All existing integration tests (`StringPropertyTest`, `IntegerPropertyTest`, + `NumberPropertyTest`, `ArrayPropertyTest`, etc.) must stay green after each sub-step. +- Run the full suite after each type is completed, not just at the end of the phase. + +### Docs for Phase 4 + +No user-visible behaviour change. No doc updates needed this phase. + +--- + +## Phase 5 — Eliminate `MultiTypeProcessor` + +**Goal**: `"type": ["string","null"]` is handled by `PropertyFactory` directly — it iterates +the type list, processes each type through the legacy per-type processor to collect validators +and decorators, merges them onto a single `Property`, consolidates `TypeCheckValidator` +instances into a `MultiTypeCheckValidator`, and runs universal modifiers once. +`MultiTypeProcessor` is deleted. + +### Why this approach + +After Phase 4, every keyword validator factory reads from the property's `JsonSchema` directly. +Each factory is keyed to a distinct JSON keyword (`minLength`, `minimum`, `minItems`, …), so +running multiple types' modifier lists against the same schema produces no duplicates — each +keyword fires at most once, for whichever type owns it. The `$checks` deduplication array inside +`MultiTypeProcessor` guarded against a scenario that no longer occurs. + +The sub-property architecture existed only to isolate per-type keyword validators from each +other. With the modifier system that isolation is implicit. We can therefore process each type +directly and merge the results, which eliminates `onResolve` deferral, `processSubProperties`, +`transferValidators`, and the `$checks` accumulator. + +### 5.1 — Inline multi-type handling in `PropertyFactory::create` + +When `$resolvedType` is an array, `PropertyFactory::create` takes the new path instead of +delegating to `MultiTypeProcessor`: + +1. **Construct the main property** directly (same as `AbstractValueProcessor::process` does): + `new Property($propertyName, null, $propertySchema, $json['description'] ?? '')` with + `setRequired`/`setReadOnly` applied. + +2. **Iterate types**: for each type in the array, call the legacy processor's `process()` to + obtain a sub-property (same flow as before, reusing `getSingleTypePropertyProcessor`), then: + - Collect all `TypeCheckInterface` validators from the sub-property into a `$collectedTypes` + string array (via `getTypes()`). + - Transfer all non-`TypeCheckInterface` validators onto the main property (preserving priority). + - If the sub-property has decorators, attach a `PropertyTransferDecorator` to the main + property (covers the `object` sub-type case where `ObjectInstantiationDecorator` lives + on the sub-property). + - Collect the sub-property's type hint via `getTypeHint()` into `$typeHints`. + +3. **Consolidate type check**: after the loop, if `$collectedTypes` is non-empty: + - Add one `MultiTypeCheckValidator(array_unique($collectedTypes), $property, isImplicitNullAllowed)`. + - Set the union `PropertyType`: separate `$collectedTypes` into non-null types and a + `$hasNull` flag; call `$property->setType(new PropertyType($nonNullTypes, $hasNull ?: null), ...)`. + - Add a `TypeHintDecorator` built from the `$typeHints` collected per sub-property. + +4. **Run universal modifiers** once on the main property (handles `default`, `enum`, `filter`). + `DefaultValueModifier` already handles the multi-type case — it iterates `$json['type']` + (which is the full array) and accepts the default if any type matches. + +`isImplicitNullAllowed` is determined the same way as in `AbstractPropertyProcessor`: +`$schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() && !$property->isRequired()`. + +### 5.2 — Make `applyTypeModifiers` and `applyUniversalModifiers` private + +These methods were `public` only because `MultiTypeProcessor` called them from outside +`PropertyFactory`. After this phase they have no external callers; make them `private`. + +### 5.3 — Delete `MultiTypeProcessor` and array-type branch + +Delete: +- `src/PropertyProcessor/Property/MultiTypeProcessor.php` +- The `is_array($type)` branch in `PropertyProcessorFactory::getProcessor` + +### Tests for Phase 5 **[DONE]** + +- `tests/Objects/MultiTypePropertyTest.php` — all existing cases pass. +- Three `invalidRecursiveMultiTypeDataProvider` expectations updated: the outer `InvalidItemException` + property name changes from `"item of array property"` to `"property"`. This is a cosmetic difference + in error message wording caused by the change in which `ArrayItemValidator` instance registers the + extracted method first. Both messages are semantically valid. The inner error messages are unchanged. +- Full suite green (2254 tests, 1 pre-existing deprecation). + +--- + +## Phase 6 — `ObjectProcessor` as modifier; `object` modifier list complete + +**Goal**: The `'object'` type modifier list in `Draft07` now includes an `ObjectModifier` that +handles nested-object instantiation (what `ObjectProcessor::process` does today) in addition to +the keyword modifiers from Phase 4. `ObjectProcessor` is then deprecated and the legacy bridge +for `type=object` is removed. + +### 6.1 — `ObjectModifier` + +New file `src/Draft/Modifier/ObjectType/ObjectModifier.php` — extracts the nested schema +processing logic from `ObjectProcessor::process`: +- Calls `$schemaProcessor->processSchema(...)` to generate the nested class +- Adds `ObjectInstantiationDecorator`, `InstanceOfValidator`, sets `PropertyType` to class name +- Handles namespace transfer + +### 6.2 — Remove legacy bridge for `type=object` + +Once `ObjectModifier` is registered in `Draft07`'s `object` modifier list and the Phase 4 +object keyword modifiers are also there, `ObjectProcessor` can be deprecated. + +### 6.3 — `BaseProcessor` pipeline step + +`BaseProcessor` handles the root-level object (`type=base`). Its pipeline steps are: +1. `setUpDefinitionDictionary` — stays in `SchemaProcessor` (internal mechanic) +2. All `object` keyword modifiers (now in `Draft07`) — called by `PropertyFactory` +3. `transferComposedPropertiesToSchema` — stays as explicit post-step in `SchemaProcessor` + +`SchemaProcessor::generateModel` calls `PropertyFactory::create` with `type=base`, which the +factory translates to constructing a `BaseProperty` and running the `object` modifier list. +`type=base` is no longer a type string in the Draft — `PropertyFactory` detects it as the +special root-schema signal: + +```php +if ($json['type'] === 'base') { + $property = new BaseProperty($propertyName, new PropertyType('object'), $propertySchema); + $types = ['object']; +} else { + $property = new Property(...); + $types = $this->resolveTypes($json); +} +``` + +This is the minimal special-casing agreed in Q2.1 (Option A from the analysis). + +### Tests for Phase 6 + +- All object-property and nested-object integration tests must stay green. +- `ObjectPropertyTest`, `IdenticalNestedSchemaTest`, `ReferencePropertyTest`. + +--- + +## Phase 7 — `CompositionModifier`; eliminate `ComposedValueProcessorFactory` + +**Goal**: Replace `AbstractComposedValueProcessor` and `ComposedValueProcessorFactory` with a +single `CompositionModifier` universal modifier. This is the highest-risk phase. + +### Bridge-period debt status + +All PMC-mutation workarounds were **fully resolved in Phase 2**: + +- `AbstractComposedValueProcessor::getCompositionProperties` save/restore → replaced by + passing `$property->isRequired()` as `bool $required` to `PropertyFactory::create`. +- `AdditionalPropertiesValidator`, `PatternPropertiesValidator`, `ArrayTupleValidator` + save/restore → replaced by passing `$required = true` to `PropertyFactory::create`. +- `SchemaDefinition::resolveReference` PMC parameter → replaced by `bool $required`. +- `PropertyMetaDataCollection` class deleted entirely. + +`NotProcessor::setRequired(true)` remains as the mechanism to force strict null-checks in +`not` composition elements. This is semantically correct: the property IS required from the +`not` branch's perspective (it must be non-null to be checked against the negated schema). +It propagates correctly via `$property->isRequired()` in `getCompositionProperties`. No +change is needed here in Phase 7. + +### 7.1 — `CompositionModifier` + +New file `src/Draft/Modifier/CompositionModifier.php`. Extracts the logic from +`AbstractPropertyProcessor::addComposedValueValidator`: +- Iterates `['allOf','anyOf','oneOf','not','if']` keywords +- Uses `$property instanceof BaseProperty` to determine root-level (replacing `rootLevelComposition`) +- Creates `CompositionPropertyDecorator` instances, sets up `onResolve` callbacks, + emits `ComposedPropertyValidator` +- Calls `SchemaProcessor::createMergedProperty` for non-root non-allOf compositions +- Handles `not`-keyword strict-null enforcement **without** mutating `isRequired` on the + composition property — instead passes an explicit `allowImplicitNull=false` signal through the + sub-property creation API (see bridge-period debt above). + +The `AbstractComposedValueProcessor` subclasses (`AllOfProcessor`, `AnyOfProcessor`, +`OneOfProcessor`, `NotProcessor`, `IfProcessor`) are replaced by the logic inside +`CompositionModifier` (using `match($keyword)` or strategy objects for the +`getComposedValueValidation` difference between allOf/anyOf/oneOf/not/if). + +### 7.2 — `ComposedValueProcessorFactory` deletion + +Once `CompositionModifier` is in the universal modifier list of `Draft07` and handles both +root-level and property-level composition, `ComposedValueProcessorFactory` and the +composition processor classes are deleted. + +### 7.3 — `ConstProcessor` and `ReferenceProcessor` + +These are special non-Draft-driven processors (they are not JSON Schema types but routing +signals injected by `PropertyFactory`). They remain as-is, invoked by the legacy bridge for +`type=const` and `type=reference`/`type=baseReference` special cases. These are retained as +permanent special-case routes inside `PropertyFactory` — they represent keyword-level routing +(the `$ref` and `const` keywords), not type-level routing, so they do not belong in the Draft. + +### Tests for Phase 7 + +- All `ComposedValue/` test classes must stay green. +- `ComposedAnyOfTest`, `ComposedAllOfTest`, `ComposedOneOfTest`, `ComposedNotTest`, + `ComposedIfTest`, `CrossTypedCompositionTest`, `ComposedRequiredPromotionTest`. +- All issue regression tests that involve composition: + `Issue98Test`, `Issue101Test`, `Issue105Test`, `Issue113Test`, `Issue114Test`, + `Issue116Test`, `Issue117Test`. +- Verify that the PMC-mutation workarounds from Phase 3 are **gone** — confirmed by the absence + of `setPropertyMetaData` calls outside of `SchemaDefinition::resolveReference` and + `Schema`'s own initialisation code. + +--- + +## Phase 8 — Delete empty processor classes and legacy bridge + +**Goal**: Remove all now-empty or deprecated processor classes, the legacy bridge in +`PropertyFactory`, `PropertyProcessorFactory`, `ComposedValueProcessorFactory`, and +`AbstractPropertyProcessor`/`AbstractValueProcessor`/`AbstractTypedValueProcessor`. + +### Remaining bridge-period debt to clean up + +At the time Phase 8 runs, the following bridge artifact must be removed: + +- The dedup guard in `AbstractTypedValueProcessor::generateValidators` (checking for existing + `TypeCheckInterface` validators) is only needed during the bridge period; it is deleted when + `AbstractTypedValueProcessor` itself is deleted. + +All PMC-mutation workarounds were already resolved in Phase 2. + +### Classes to delete + +- `src/PropertyProcessor/Property/AbstractPropertyProcessor.php` +- `src/PropertyProcessor/Property/AbstractValueProcessor.php` +- `src/PropertyProcessor/Property/AbstractTypedValueProcessor.php` +- `src/PropertyProcessor/Property/AbstractNumericProcessor.php` +- `src/PropertyProcessor/Property/StringProcessor.php` +- `src/PropertyProcessor/Property/IntegerProcessor.php` +- `src/PropertyProcessor/Property/NumberProcessor.php` +- `src/PropertyProcessor/Property/BooleanProcessor.php` +- `src/PropertyProcessor/Property/NullProcessor.php` +- `src/PropertyProcessor/Property/ArrayProcessor.php` +- `src/PropertyProcessor/Property/ObjectProcessor.php` +- `src/PropertyProcessor/Property/AnyProcessor.php` +- `src/PropertyProcessor/Property/MultiTypeProcessor.php` (deleted in Phase 5) +- `src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php` +- `src/PropertyProcessor/ComposedValue/AllOfProcessor.php` +- `src/PropertyProcessor/ComposedValue/AnyOfProcessor.php` +- `src/PropertyProcessor/ComposedValue/OneOfProcessor.php` +- `src/PropertyProcessor/ComposedValue/NotProcessor.php` +- `src/PropertyProcessor/ComposedValue/IfProcessor.php` +- `src/PropertyProcessor/ComposedValueProcessorFactory.php` +- `src/PropertyProcessor/PropertyProcessorFactory.php` +- `src/PropertyProcessor/ProcessorFactoryInterface.php` +- `src/PropertyProcessor/PropertyProcessorInterface.php` + +### Tests to delete/replace + +- `tests/PropertyProcessor/PropertyProcessorFactoryTest.php` — tests the deleted factory. + Replace with `tests/Draft/DraftRegistryTest.php` testing that `Draft07` returns the correct + modifier list for each type. + +### Remaining processors (kept permanently) + +- `src/PropertyProcessor/Property/ConstProcessor.php` — becomes a standalone callable, no + longer part of the processor hierarchy; or folds into `PropertyFactory` directly +- `src/PropertyProcessor/Property/ReferenceProcessor.php` — same +- `src/PropertyProcessor/Property/BasereferenceProcessor.php` — same + +### Docs for Phase 8 + +- Remove all references to `PropertyProcessorFactory` from docs. +- Update architecture overview in docs and `CLAUDE.md`. +- Document the final `DraftInterface` / modifier system for users. + +--- + +## Cross-cutting: `AutoDetectionDraft` completion + +This runs in parallel with Phases 3–8 as each draft keyword is modelled. + +### Detection logic + +Inspect `$schema` keyword: +- `http://json-schema.org/draft-07/schema#` → `Draft07` +- `http://json-schema.org/draft-04/schema#` → `Draft04` (once implemented) +- `https://json-schema.org/draft/2020-12/schema` → `Draft202012` (once implemented) +- absent / unrecognised → `Draft07` (safe default, matches current behaviour) + +The detection runs per-`JsonSchema` (per file/component), not per generator run, so schemas +with different `$schema` declarations in the same generation run can use different drafts. + +### Where detection is called + +`PropertyFactory::create` already receives `$propertySchema: JsonSchema`. It calls +`$config->getDraft()` and, if the result is `AutoDetectionDraft`, calls +`getDraftForSchema($propertySchema)` to get the concrete draft. This is the single +detection point — no other caller needs to know about drafts. + +--- + +## Test suite impact summary + +| Phase | Tests added | Tests changed | Tests deleted | +|---|---|---|---| +| 1 | `tests/Draft/DraftTest.php` | `GeneratorConfigurationTest` (new `setDraft` test) | — | +| 2 | — | `PropertyProcessorFactoryTest` (remove `PropertyMetaDataCollection` arg) | — | +| 3 | `tests/Draft/Modifier/TypeCheckModifierTest.php`, `DefaultValueModifierTest.php` | Full suite regression | — | +| 4 | One unit test per new modifier | All type-specific integration tests (regression) | — | +| 5 | New multi-type edge cases in `MultiTypePropertyTest` | `MultiTypePropertyTest` | — | +| 6 | — | `ObjectPropertyTest`, `IdenticalNestedSchemaTest` | — | +| 7 | — | All `ComposedValue/*Test`, all composition-related issue tests | — | +| 8 | `tests/Draft/DraftRegistryTest.php` | — | `PropertyProcessorFactoryTest` | + +--- + +## Completion criteria + +Each phase is complete when: +1. All new/changed code passes `./vendor/bin/phpcs --standard=phpcs.xml` +2. `./vendor/bin/phpunit` is fully green +3. The plan file is updated with "DONE" on that phase +4. The phase is committed as a standalone PR + +The entire rework is complete when Phase 8 is merged and this tracking directory is deleted +before the merge to `master`. diff --git a/src/PropertyProcessor/ProcessorFactoryInterface.php b/src/PropertyProcessor/ProcessorFactoryInterface.php index 3e239702..fcd7a8ad 100644 --- a/src/PropertyProcessor/ProcessorFactoryInterface.php +++ b/src/PropertyProcessor/ProcessorFactoryInterface.php @@ -14,11 +14,8 @@ */ interface ProcessorFactoryInterface { - /** - * @param string|array $type - */ public function getProcessor( - $type, + string $type, SchemaProcessor $schemaProcessor, Schema $schema, bool $required = false, diff --git a/src/PropertyProcessor/Property/MultiTypeProcessor.php b/src/PropertyProcessor/Property/MultiTypeProcessor.php deleted file mode 100644 index 1153a221..00000000 --- a/src/PropertyProcessor/Property/MultiTypeProcessor.php +++ /dev/null @@ -1,216 +0,0 @@ -propertyProcessors[$type] = $this->propertyProcessorFactory->getProcessor( - $type, - $schemaProcessor, - $schema, - $required, - ); - } - } - - /** - * Process a property - * - * @param string $propertyName The name of the property - * @param JsonSchema $propertySchema The schema of the property - * - * @throws SchemaException - * @throws ReflectionException - */ - public function process(string $propertyName, JsonSchema $propertySchema): PropertyInterface - { - $property = parent::process($propertyName, $propertySchema); - - $property->onResolve(function () use ($property, $propertyName, $propertySchema): void { - foreach ($property->getValidators() as $validator) { - $this->checks[] = $validator->getValidator()->getCheck(); - } - - $subProperties = $this->processSubProperties($propertyName, $propertySchema, $property); - - $processedSubProperties = 0; - foreach ($subProperties as $subProperty) { - $subProperty->onResolve(function () use ($property, $subProperties, &$processedSubProperties): void { - if (++$processedSubProperties === count($subProperties)) { - if (empty($this->allowedPropertyTypes)) { - return; - } - - $property->addTypeHintDecorator( - new TypeHintDecorator( - array_map( - static fn(PropertyInterface $subProperty): string => $subProperty->getTypeHint(), - $subProperties, - ) - ), - ); - - $property->addValidator( - new MultiTypeCheckValidator( - array_unique($this->allowedPropertyTypes), - $property, - $this->isImplicitNullAllowed($property), - ), - 2, - ); - - // Set a union PropertyType so the native PHP type hint path can emit - // e.g. float|string|array instead of falling back to no hint at all. - // 'null' must be converted to nullable=true rather than kept as a type name, - // otherwise the render pipeline would emit string|null|null. - $hasNull = in_array('null', $this->allowedPropertyTypes, true); - $nonNullTypes = array_values(array_filter( - $this->allowedPropertyTypes, - fn(string $type): bool => $type !== 'null', - )); - - if ($nonNullTypes) { - $property->setType( - new PropertyType($nonNullTypes, $hasNull ? true : null), - new PropertyType($nonNullTypes, $hasNull ? true : null), - ); - } - } - }); - } - }); - - return $property; - } - - /** - * Move validators from the $source property to the $destination property - */ - protected function transferValidators(PropertyInterface $source, PropertyInterface $destination) - { - foreach ($source->getValidators() as $validatorContainer) { - $validator = $validatorContainer->getValidator(); - - // filter out type checks to create a single type check which covers all allowed types - if ($validator instanceof TypeCheckInterface) { - array_push($this->allowedPropertyTypes, ...$validator->getTypes()); - - continue; - } - - // remove duplicated checks like an isset check - if (in_array($validator->getCheck(), $this->checks)) { - continue; - } - - $destination->addValidator($validator, $validatorContainer->getPriority()); - $this->checks[] = $validator->getCheck(); - } - } - - /** - * @return PropertyInterface[] - * - * @throws SchemaException - */ - protected function processSubProperties( - string $propertyName, - JsonSchema $propertySchema, - PropertyInterface $property, - ): array { - $defaultValue = null; - $invalidDefaultValueException = null; - $invalidDefaultValues = 0; - $subProperties = []; - $json = $propertySchema->getJson(); - - if (isset($json['default'])) { - $defaultValue = $json['default']; - unset($json['default']); - } - - $subPropertyFactory = new PropertyFactory($this->propertyProcessorFactory); - - foreach ($this->propertyProcessors as $type => $propertyProcessor) { - $json['type'] = $type; - $subSchema = $propertySchema->withJson($json); - - $subProperty = $propertyProcessor->process($propertyName, $subSchema); - $subPropertyFactory->applyTypeModifiers( - $this->schemaProcessor, - $this->schema, - $subProperty, - $subSchema, - ); - - $subProperty->onResolve(function () use ($property, $subProperty): void { - $this->transferValidators($subProperty, $property); - - if ($subProperty->getDecorators()) { - $property->addDecorator(new PropertyTransferDecorator($subProperty)); - } - }); - - if ($defaultValue !== null && $propertyProcessor instanceof AbstractTypedValueProcessor) { - try { - $propertyProcessor->setDefaultValue($property, $defaultValue, $propertySchema); - } catch (SchemaException $e) { - $invalidDefaultValues++; - $invalidDefaultValueException = $e; - } - } - - $subProperties[] = $subProperty; - } - - if ($invalidDefaultValues === count($this->propertyProcessors)) { - throw $invalidDefaultValueException; - } - - return $subProperties; - } -} diff --git a/src/PropertyProcessor/PropertyFactory.php b/src/PropertyProcessor/PropertyFactory.php index 49dcb7be..c2b5c5d9 100644 --- a/src/PropertyProcessor/PropertyFactory.php +++ b/src/PropertyProcessor/PropertyFactory.php @@ -7,9 +7,15 @@ use PHPModelGenerator\Draft\Draft; use PHPModelGenerator\Draft\DraftFactoryInterface; use PHPModelGenerator\Exception\SchemaException; +use PHPModelGenerator\Model\Property\Property; use PHPModelGenerator\Model\Property\PropertyInterface; +use PHPModelGenerator\Model\Property\PropertyType; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; +use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator; +use PHPModelGenerator\Model\Validator\TypeCheckInterface; +use PHPModelGenerator\PropertyProcessor\Decorator\Property\PropertyTransferDecorator; +use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; /** @@ -52,6 +58,19 @@ public function create( $resolvedType = $json['type'] ?? 'any'; + if (is_array($resolvedType)) { + return $this->createMultiTypeProperty( + $schemaProcessor, + $schema, + $propertyName, + $propertySchema, + $resolvedType, + $required, + ); + } + + $this->checkType($resolvedType, $schema); + $property = $this->processorFactory ->getProcessor( $resolvedType, @@ -61,25 +80,160 @@ public function create( ) ->process($propertyName, $propertySchema); - if (is_array($resolvedType)) { - // For multi-type properties the type-specific modifiers run per sub-property inside - // MultiTypeProcessor via applyTypeModifiers(). Only the universal modifiers run here. - $this->applyUniversalModifiers($schemaProcessor, $schema, $property, $propertySchema); - } else { - $this->applyDraftModifiers($schemaProcessor, $schema, $property, $propertySchema); + $this->applyDraftModifiers($schemaProcessor, $schema, $property, $propertySchema); + + return $property; + } + + /** + * Handle "type": [...] properties by processing each type through its legacy processor, + * merging validators and decorators onto a single property, then consolidating type checks. + * + * @param string[] $types + * + * @throws SchemaException + */ + private function createMultiTypeProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + array $types, + bool $required, + ): PropertyInterface { + $json = $propertySchema->getJson(); + + $property = (new Property( + $propertyName, + null, + $propertySchema, + $json['description'] ?? '', + )) + ->setRequired($required) + ->setReadOnly( + (isset($json['readOnly']) && $json['readOnly'] === true) || + $schemaProcessor->getGeneratorConfiguration()->isImmutable(), + ); + + $collectedTypes = []; + $typeHints = []; + $resolvedSubCount = 0; + $totalSubCount = count($types); + + // Strip the default from sub-schemas so that default handling runs only once via the + // universal DefaultValueModifier below, which already handles the multi-type case. + $subJson = $json; + unset($subJson['default']); + + foreach ($types as $type) { + $this->checkType($type, $schema); + + $subJson['type'] = $type; + $subSchema = $propertySchema->withJson($subJson); + + $subProperty = $this->processorFactory + ->getProcessor($type, $schemaProcessor, $schema, $required) + ->process($propertyName, $subSchema); + + $this->applyTypeModifiers($schemaProcessor, $schema, $subProperty, $subSchema); + + $subProperty->onResolve(function () use ( + $property, + $subProperty, + $schemaProcessor, + $schema, + $propertySchema, + $totalSubCount, + &$collectedTypes, + &$typeHints, + &$resolvedSubCount, + ): void { + foreach ($subProperty->getValidators() as $validatorContainer) { + $validator = $validatorContainer->getValidator(); + + if ($validator instanceof TypeCheckInterface) { + array_push($collectedTypes, ...$validator->getTypes()); + continue; + } + + $property->addValidator($validator, $validatorContainer->getPriority()); + } + + if ($subProperty->getDecorators()) { + $property->addDecorator(new PropertyTransferDecorator($subProperty)); + } + + $typeHints[] = $subProperty->getTypeHint(); + + if (++$resolvedSubCount < $totalSubCount || empty($collectedTypes)) { + return; + } + + $this->finalizeMultiTypeProperty( + $property, + array_unique($collectedTypes), + $typeHints, + $schemaProcessor, + $schema, + $propertySchema, + ); + }); } return $property; } + /** + * Called once all sub-properties of a multi-type property have resolved. + * Adds the consolidated MultiTypeCheckValidator, sets the union PropertyType, + * attaches the type-hint decorator, and runs universal modifiers. + * + * @param string[] $collectedTypes + * @param string[] $typeHints + * + * @throws SchemaException + */ + private function finalizeMultiTypeProperty( + PropertyInterface $property, + array $collectedTypes, + array $typeHints, + SchemaProcessor $schemaProcessor, + Schema $schema, + JsonSchema $propertySchema, + ): void { + $hasNull = in_array('null', $collectedTypes, true); + $nonNullTypes = array_values(array_filter( + $collectedTypes, + static fn(string $t): bool => $t !== 'null', + )); + + $allowImplicitNull = $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() + && !$property->isRequired(); + + $property->addValidator( + new MultiTypeCheckValidator($collectedTypes, $property, $allowImplicitNull), + 2, + ); + + if ($nonNullTypes) { + $property->setType( + new PropertyType($nonNullTypes, $hasNull ? true : null), + new PropertyType($nonNullTypes, $hasNull ? true : null), + ); + } + + $property->addTypeHintDecorator(new TypeHintDecorator($typeHints)); + + $this->applyUniversalModifiers($schemaProcessor, $schema, $property, $propertySchema); + } + /** * Run only the type-specific Draft modifiers (no universal 'any' modifiers) for the given - * property. Used by MultiTypeProcessor to apply per-type modifiers to each sub-property - * without double-applying universal modifiers that run separately on the main property. + * property. * * @throws SchemaException */ - public function applyTypeModifiers( + private function applyTypeModifiers( SchemaProcessor $schemaProcessor, Schema $schema, PropertyInterface $property, @@ -108,7 +262,7 @@ public function applyTypeModifiers( * * @throws SchemaException */ - public function applyUniversalModifiers( + private function applyUniversalModifiers( SchemaProcessor $schemaProcessor, Schema $schema, PropertyInterface $property, @@ -164,6 +318,24 @@ private function applyDraftModifiers( } } + /** + * @throws SchemaException + */ + private function checkType(mixed $type, Schema $schema): void + { + if (is_string($type)) { + return; + } + + throw new SchemaException( + sprintf( + 'Invalid property type %s in file %s', + $type, + $schema->getJsonSchema()->getFile(), + ) + ); + } + private function resolveBuiltDraft(SchemaProcessor $schemaProcessor, JsonSchema $propertySchema): Draft { $configDraft = $schemaProcessor->getGeneratorConfiguration()->getDraft(); diff --git a/src/PropertyProcessor/PropertyProcessorFactory.php b/src/PropertyProcessor/PropertyProcessorFactory.php index ed798cc0..aa569831 100644 --- a/src/PropertyProcessor/PropertyProcessorFactory.php +++ b/src/PropertyProcessor/PropertyProcessorFactory.php @@ -6,7 +6,6 @@ use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Schema; -use PHPModelGenerator\PropertyProcessor\Property\MultiTypeProcessor; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; /** @@ -17,37 +16,9 @@ class PropertyProcessorFactory implements ProcessorFactoryInterface { /** - * @param string|array $type - * * @throws SchemaException */ public function getProcessor( - $type, - SchemaProcessor $schemaProcessor, - Schema $schema, - bool $required = false, - ): PropertyProcessorInterface { - if (is_string($type)) { - return $this->getSingleTypePropertyProcessor($type, $schemaProcessor, $schema, $required); - } - - if (is_array($type)) { - return new MultiTypeProcessor($this, $type, $schemaProcessor, $schema, $required); - } - - throw new SchemaException( - sprintf( - 'Invalid property type %s in file %s', - $type, - $schema->getJsonSchema()->getFile(), - ) - ); - } - - /** - * @throws SchemaException - */ - protected function getSingleTypePropertyProcessor( string $type, SchemaProcessor $schemaProcessor, Schema $schema, diff --git a/tests/Objects/MultiTypePropertyTest.php b/tests/Objects/MultiTypePropertyTest.php index 78695eee..895cd6e7 100644 --- a/tests/Objects/MultiTypePropertyTest.php +++ b/tests/Objects/MultiTypePropertyTest.php @@ -285,7 +285,7 @@ public static function invalidRecursiveMultiTypeDataProvider(): array ['Test1', 1], InvalidItemException::class, << Date: Sat, 28 Mar 2026 11:47:45 +0100 Subject: [PATCH 6/9] Implement Phase 6: migrate object keyword processing to Draft modifier architecture - Convert MinProperties, MaxProperties, PropertyNames, PatternProperties, AdditionalProperties, and Properties processing from BaseProcessor methods into validator factories (Factory/Object/) registered via addValidator() in Draft_07, consistent with the scalar/array/number/string factory pattern - ObjectModifier remains a proper modifier (structural, not keyword-driven) - PropertyFactory: add type=object path that calls processSchema directly, wires the outer property via TypeCheckModifier + ObjectModifier, and runs universal modifiers (filter/enum/default) on the outer property - PropertyFactory: add RequiredPropertyValidator for required type=object properties; strip property-level keywords before passing schema to processSchema to prevent double-application on the nested class root - SchemaProcessor: add transferComposedPropertiesToSchema (migrated from BaseProcessor) with correct use imports so allOf/anyOf/oneOf branch properties are transferred and conflict detection fires correctly - BaseProcessor: remove all methods now handled by Draft modifiers/factories --- src/Draft/Draft_07.php | 16 +- .../Modifier/ObjectType/ObjectModifier.php | 76 +++++++ .../AdditionalPropertiesValidatorFactory.php | 60 ++++++ .../Object/MaxPropertiesValidatorFactory.php | 39 ++++ .../Object/MinPropertiesValidatorFactory.php | 40 ++++ .../PatternPropertiesValidatorFactory.php | 51 +++++ .../Object/PropertiesValidatorFactory.php | 140 +++++++++++++ .../Object/PropertyNamesValidatorFactory.php | 40 ++++ .../Property/BaseProcessor.php | 10 - src/PropertyProcessor/PropertyFactory.php | 115 +++++++++- src/SchemaProcessor/SchemaProcessor.php | 198 ++++++++++++++++++ 11 files changed, 773 insertions(+), 12 deletions(-) create mode 100644 src/Draft/Modifier/ObjectType/ObjectModifier.php create mode 100644 src/Model/Validator/Factory/Object/AdditionalPropertiesValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Object/MaxPropertiesValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Object/MinPropertiesValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Object/PropertyNamesValidatorFactory.php diff --git a/src/Draft/Draft_07.php b/src/Draft/Draft_07.php index 776dba10..4f0cd9e4 100644 --- a/src/Draft/Draft_07.php +++ b/src/Draft/Draft_07.php @@ -7,6 +7,7 @@ use PHPModelGenerator\Draft\Element\Type; use PHPModelGenerator\Draft\Modifier\DefaultArrayToEmptyArrayModifier; use PHPModelGenerator\Draft\Modifier\DefaultValueModifier; +use PHPModelGenerator\Draft\Modifier\ObjectType\ObjectModifier; use PHPModelGenerator\Model\Validator\Factory\Any\EnumValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\Any\FilterValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\Arrays\ContainsValidatorFactory; @@ -19,6 +20,12 @@ use PHPModelGenerator\Model\Validator\Factory\Number\MaximumValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\Number\MinimumValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\Number\MultipleOfPropertyValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Object\AdditionalPropertiesValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Object\PropertiesValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Object\MaxPropertiesValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Object\MinPropertiesValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Object\PatternPropertiesValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Object\PropertyNamesValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\String\FormatValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\String\MaxLengthValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\String\MinLengthPropertyValidatorFactory; @@ -29,7 +36,14 @@ class Draft_07 implements DraftInterface public function getDefinition(): DraftBuilder { return (new DraftBuilder()) - ->addType(new Type('object', false)) + ->addType((new Type('object')) + ->addValidator('properties', new PropertiesValidatorFactory()) + ->addValidator('propertyNames', new PropertyNamesValidatorFactory()) + ->addValidator('patternProperties', new PatternPropertiesValidatorFactory()) + ->addValidator('additionalProperties', new AdditionalPropertiesValidatorFactory()) + ->addValidator('minProperties', new MinPropertiesValidatorFactory()) + ->addValidator('maxProperties', new MaxPropertiesValidatorFactory()) + ->addModifier(new ObjectModifier())) ->addType((new Type('array')) ->addValidator('items', new ItemsValidatorFactory()) ->addValidator('minItems', new MinItemsValidatorFactory()) diff --git a/src/Draft/Modifier/ObjectType/ObjectModifier.php b/src/Draft/Modifier/ObjectType/ObjectModifier.php new file mode 100644 index 00000000..74164efe --- /dev/null +++ b/src/Draft/Modifier/ObjectType/ObjectModifier.php @@ -0,0 +1,76 @@ +getNestedSchema(); + if ($nestedSchema === null) { + return; + } + + if ( + $nestedSchema->getClassPath() !== $schema->getClassPath() || + $nestedSchema->getClassName() !== $schema->getClassName() + ) { + $schema->addUsedClass( + join( + '\\', + array_filter([ + $schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(), + $nestedSchema->getClassPath(), + $nestedSchema->getClassName(), + ]), + ) + ); + + $schema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($nestedSchema)); + } + + $property + ->addDecorator( + new ObjectInstantiationDecorator( + $nestedSchema->getClassName(), + $schemaProcessor->getGeneratorConfiguration(), + ) + ) + ->setType(new PropertyType($nestedSchema->getClassName())); + + $property->addValidator(new InstanceOfValidator($property), 3); + } +} diff --git a/src/Model/Validator/Factory/Object/AdditionalPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/AdditionalPropertiesValidatorFactory.php new file mode 100644 index 00000000..d9faaf66 --- /dev/null +++ b/src/Model/Validator/Factory/Object/AdditionalPropertiesValidatorFactory.php @@ -0,0 +1,60 @@ +getJson(); + + if ( + !isset($json[$this->key]) && + $schemaProcessor->getGeneratorConfiguration()->denyAdditionalProperties() + ) { + $json[$this->key] = false; + } + + if (!isset($json[$this->key]) || $json[$this->key] === true) { + return; + } + + if (!is_bool($json[$this->key])) { + $schema->addBaseValidator( + new AdditionalPropertiesValidator( + $schemaProcessor, + $schema, + $propertySchema, + ) + ); + + return; + } + + $schema->addBaseValidator( + new NoAdditionalPropertiesValidator( + new Property($schema->getClassName(), null, $propertySchema), + $json, + ) + ); + } +} diff --git a/src/Model/Validator/Factory/Object/MaxPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/MaxPropertiesValidatorFactory.php new file mode 100644 index 00000000..e92ef12a --- /dev/null +++ b/src/Model/Validator/Factory/Object/MaxPropertiesValidatorFactory.php @@ -0,0 +1,39 @@ +_rawModelDataInput), + array_keys($modelData), + ) + ), + )'; + + protected function isValueValid(mixed $value): bool + { + return is_int($value) && $value >= 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + sprintf('%s > %d', self::COUNT_PROPERTIES, $value), + MaxPropertiesException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Object/MinPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/MinPropertiesValidatorFactory.php new file mode 100644 index 00000000..5d90d6e2 --- /dev/null +++ b/src/Model/Validator/Factory/Object/MinPropertiesValidatorFactory.php @@ -0,0 +1,40 @@ +_rawModelDataInput), + array_keys($modelData), + ) + ), + )'; + + protected function isValueValid(mixed $value): bool + { + return is_int($value) && $value >= 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + sprintf('%s < %d', self::COUNT_PROPERTIES, $value), + MinPropertiesException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php new file mode 100644 index 00000000..026aeeab --- /dev/null +++ b/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php @@ -0,0 +1,51 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + foreach ($json[$this->key] as $pattern => $patternSchema) { + $escapedPattern = addcslashes((string) $pattern, '/'); + + if (@preg_match("/$escapedPattern/", '') === false) { + throw new SchemaException( + "Invalid pattern '$pattern' for pattern property in file {$propertySchema->getFile()}", + ); + } + + $schema->addBaseValidator( + new PatternPropertiesValidator( + $schemaProcessor, + $schema, + $pattern, + $propertySchema->withJson($patternSchema), + ) + ); + } + } +} diff --git a/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php new file mode 100644 index 00000000..c22f9c6e --- /dev/null +++ b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php @@ -0,0 +1,140 @@ +getJson(); + + $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); + + $json[$this->key] ??= []; + // Setup empty properties for required properties which aren't defined in the properties section + $json[$this->key] += array_fill_keys( + array_diff($json['required'] ?? [], array_keys($json[$this->key])), + [], + ); + + foreach ($json[$this->key] as $propertyName => $propertyStructure) { + if ($propertyStructure === false) { + if (in_array($propertyName, $json['required'] ?? [], true)) { + throw new SchemaException( + sprintf( + "Property '%s' is denied (schema false) but also listed as required in file %s", + $propertyName, + $propertySchema->getFile(), + ), + ); + } + + $schema->addBaseValidator( + new PropertyValidator( + new Property($propertyName, null, $propertySchema->withJson([])), + "array_key_exists('" . addslashes($propertyName) . "', \$modelData)", + DeniedPropertyException::class, + ) + ); + continue; + } + + $required = in_array($propertyName, $json['required'] ?? [], true); + $dependencies = $json['dependencies'][$propertyName] ?? null; + $nestedProperty = $propertyFactory->create( + $schemaProcessor, + $schema, + (string) $propertyName, + $propertySchema->withJson( + $dependencies !== null + ? $propertyStructure + ['_dependencies' => $dependencies] + : $propertyStructure, + ), + $required, + ); + + if ($dependencies !== null) { + $this->addDependencyValidator($nestedProperty, $dependencies, $schemaProcessor, $schema); + } + + $schema->addProperty($nestedProperty); + } + } + + /** + * @throws SchemaException + */ + private function addDependencyValidator( + PropertyInterface $property, + array $dependencies, + SchemaProcessor $schemaProcessor, + Schema $schema, + ): void { + $propertyDependency = true; + + array_walk( + $dependencies, + static function ($dependency, $index) use (&$propertyDependency): void { + $propertyDependency = $propertyDependency && is_int($index) && is_string($dependency); + }, + ); + + if ($propertyDependency) { + $property->addValidator(new PropertyDependencyValidator($property, $dependencies)); + + return; + } + + if (!isset($dependencies['type'])) { + $dependencies['type'] = 'object'; + } + + $dependencySchema = $schemaProcessor->processSchema( + new JsonSchema($schema->getJsonSchema()->getFile(), $dependencies), + $schema->getClassPath(), + "{$schema->getClassName()}_{$property->getName()}_Dependency", + $schema->getSchemaDictionary(), + ); + + $property->addValidator(new SchemaDependencyValidator($schemaProcessor, $property, $dependencySchema)); + $schema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($dependencySchema)); + + $this->transferDependentPropertiesToBaseSchema($dependencySchema, $schema); + } + + private function transferDependentPropertiesToBaseSchema(Schema $dependencySchema, Schema $schema): void + { + foreach ($dependencySchema->getProperties() as $dependencyProperty) { + $schema->addProperty( + (clone $dependencyProperty) + ->setRequired(false) + ->setType(null) + ->filterValidators(static fn(): bool => false), + ); + } + } +} diff --git a/src/Model/Validator/Factory/Object/PropertyNamesValidatorFactory.php b/src/Model/Validator/Factory/Object/PropertyNamesValidatorFactory.php new file mode 100644 index 00000000..dd8db8e2 --- /dev/null +++ b/src/Model/Validator/Factory/Object/PropertyNamesValidatorFactory.php @@ -0,0 +1,40 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $schema->addBaseValidator( + new PropertyNamesValidator( + $schemaProcessor, + $schema, + $propertySchema->withJson($json[$this->key]), + ) + ); + } +} diff --git a/src/PropertyProcessor/Property/BaseProcessor.php b/src/PropertyProcessor/Property/BaseProcessor.php index 1ed779b5..6bc9d4b1 100644 --- a/src/PropertyProcessor/Property/BaseProcessor.php +++ b/src/PropertyProcessor/Property/BaseProcessor.php @@ -73,16 +73,6 @@ public function process(string $propertyName, JsonSchema $propertySchema): Prope $property = new BaseProperty($propertyName, new PropertyType(static::TYPE), $propertySchema); $this->generateValidators($property, $propertySchema); - $this->addPropertiesToSchema($propertySchema); - $this->transferComposedPropertiesToSchema($property); - - $this->addPropertyNamesValidator($propertySchema); - $this->addPatternPropertiesValidator($propertySchema); - $this->addAdditionalPropertiesValidator($propertySchema); - - $this->addMinPropertiesValidator($propertyName, $propertySchema); - $this->addMaxPropertiesValidator($propertyName, $propertySchema); - return $property; } diff --git a/src/PropertyProcessor/PropertyFactory.php b/src/PropertyProcessor/PropertyFactory.php index c2b5c5d9..440c6d0e 100644 --- a/src/PropertyProcessor/PropertyFactory.php +++ b/src/PropertyProcessor/PropertyFactory.php @@ -6,6 +6,8 @@ use PHPModelGenerator\Draft\Draft; use PHPModelGenerator\Draft\DraftFactoryInterface; +use PHPModelGenerator\Draft\Modifier\ObjectType\ObjectModifier; +use PHPModelGenerator\Draft\Modifier\TypeCheckModifier; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Property\Property; use PHPModelGenerator\Model\Property\PropertyInterface; @@ -13,10 +15,12 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator; +use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; use PHPModelGenerator\Model\Validator\TypeCheckInterface; use PHPModelGenerator\PropertyProcessor\Decorator\Property\PropertyTransferDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\TypeConverter; /** * Class PropertyFactory @@ -71,6 +75,87 @@ public function create( $this->checkType($resolvedType, $schema); + // Nested object properties: bypass the legacy ObjectProcessor. Call processSchema to + // generate the nested class, store it on the property, then run Draft modifiers with + // the nested Schema as $schema so keyword modifiers add to the correct target. + if ($resolvedType === 'object') { + $json = $propertySchema->getJson(); + $property = (new Property( + $propertyName, + null, + $propertySchema, + $json['description'] ?? '', + )) + ->setRequired($required) + ->setReadOnly( + (isset($json['readOnly']) && $json['readOnly'] === true) || + $schemaProcessor->getGeneratorConfiguration()->isImmutable(), + ); + + if ($required && !str_starts_with($propertyName, 'item of array ')) { + $property->addValidator(new RequiredPropertyValidator($property), 1); + } + + $className = $schemaProcessor->getGeneratorConfiguration()->getClassNameGenerator()->getClassName( + $propertyName, + $propertySchema, + false, + $schemaProcessor->getCurrentClassName(), + ); + + // Strip property-level keywords (filter, enum, default) before passing the schema to + // processSchema. These keywords target the outer property — not the nested class root — + // and are handled by applyUniversalModifiers below after processSchema returns. + $nestedJson = $json; + unset($nestedJson['filter'], $nestedJson['enum'], $nestedJson['default']); + $nestedSchema = $schemaProcessor->processSchema( + $propertySchema->withJson($nestedJson), + $schemaProcessor->getCurrentClassPath(), + $className, + $schema->getSchemaDictionary(), + ); + + if ($nestedSchema !== null) { + // Store on the property so ObjectModifier can read it. + $property->setNestedSchema($nestedSchema); + + // processSchema already ran all schema-targeting Draft modifiers (PropertiesModifier, + // PatternPropertiesModifier, etc.) on the nested schema internally via the type=base + // path. Here we only wire the outer property: add the type-check validator and the + // instantiation linkage. Passing the outer $schema ensures addUsedClass and + // addNamespaceTransferDecorator target the correct parent class. + $this->wireObjectProperty($schemaProcessor, $schema, $property, $propertySchema); + } + + // Universal modifiers (filter, enum, default) must still run on the outer property + // with the outer $schema context. They are property-targeting, not schema-targeting, + // so they must not be applied inside processSchema (which targets the nested class). + $this->applyUniversalModifiers($schemaProcessor, $schema, $property, $propertySchema); + + return $property; + } + + // Root-level schema: run the legacy BaseProcessor bridge (handles setUpDefinitionDictionary + // and composition validators), then Draft modifiers (PropertiesModifier etc. — must run + // BEFORE transferComposedPropertiesToSchema so root properties are registered first and + // the allOf merger can narrow them correctly), then composition property transfer. + // ObjectModifier skips for BaseProperty instances. + // The propertySchema is rewritten from type=base to type=object so applyDraftModifiers + // correctly resolves the 'object' modifier list from the Draft. + if ($resolvedType === 'base') { + $property = $this->processorFactory + ->getProcessor('base', $schemaProcessor, $schema, $required) + ->process($propertyName, $propertySchema); + + $objectJson = $json; + $objectJson['type'] = 'object'; + $this->applyDraftModifiers($schemaProcessor, $schema, $property, $propertySchema->withJson($objectJson)); + + $schemaProcessor->transferComposedPropertiesToSchema($property, $schema); + + return $property; + } + $property = $this->processorFactory ->getProcessor( $resolvedType, @@ -135,7 +220,12 @@ private function createMultiTypeProperty( ->getProcessor($type, $schemaProcessor, $schema, $required) ->process($propertyName, $subSchema); - $this->applyTypeModifiers($schemaProcessor, $schema, $subProperty, $subSchema); + // For type=object, ObjectProcessor::process already called processSchema (which ran + // all schema-targeting Draft modifiers) and wired the property. Running + // applyTypeModifiers would re-apply PropertiesModifier etc. to the outer $schema. + if ($type !== 'object') { + $this->applyTypeModifiers($schemaProcessor, $schema, $subProperty, $subSchema); + } $subProperty->onResolve(function () use ( $property, @@ -227,6 +317,29 @@ private function finalizeMultiTypeProperty( $this->applyUniversalModifiers($schemaProcessor, $schema, $property, $propertySchema); } + /** + * Wire the outer property for a nested object: add the type-check validator and instantiation + * linkage. Schema-targeting modifiers (PropertiesModifier etc.) are intentionally NOT run here + * because processSchema already applied them to the nested schema via the type=base path. + * + * @throws SchemaException + */ + private function wireObjectProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + (new TypeCheckModifier(TypeConverter::jsonSchemaToPhp('object')))->modify( + $schemaProcessor, + $schema, + $property, + $propertySchema, + ); + + (new ObjectModifier())->modify($schemaProcessor, $schema, $property, $propertySchema); + } + /** * Run only the type-specific Draft modifiers (no universal 'any' modifiers) for the given * property. diff --git a/src/SchemaProcessor/SchemaProcessor.php b/src/SchemaProcessor/SchemaProcessor.php index bed38fee..b58c65eb 100644 --- a/src/SchemaProcessor/SchemaProcessor.php +++ b/src/SchemaProcessor/SchemaProcessor.php @@ -14,6 +14,13 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\SchemaDefinition\SchemaDefinitionDictionary; +use PHPModelGenerator\Model\Validator; +use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; +use PHPModelGenerator\Model\Validator\ComposedPropertyValidator; +use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator; +use PHPModelGenerator\Model\Validator\PropertyTemplateValidator; +use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor; +use PHPModelGenerator\PropertyProcessor\ComposedValue\ComposedPropertiesInterface; use PHPModelGenerator\PropertyProcessor\Decorator\Property\ObjectInstantiationDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator; @@ -447,6 +454,197 @@ public function processTopLevelSchema(JsonSchema $jsonSchema): ?Schema return $schema; } + /** + * Transfer properties of composed properties to the given schema to offer a complete model + * including all composed properties. + * + * This is an internal pipeline mechanic (Q5.1): not a JSON Schema keyword and therefore not + * a Draft modifier. It is called as an explicit post-step from generateModel after all Draft + * modifiers have run on the root-level BaseProperty. + * + * @throws SchemaException + */ + public function transferComposedPropertiesToSchema(PropertyInterface $property, Schema $schema): void + { + foreach ($property->getValidators() as $validator) { + $validator = $validator->getValidator(); + + if (!is_a($validator, AbstractComposedPropertyValidator::class)) { + continue; + } + + // If the transferred validator of the composed property is also a composed property + // strip the nested composition validations from the added validator. The nested + // composition will be validated in the object generated for the nested composition + // which will be executed via an instantiation. Consequently, the validation must not + // be executed in the outer composition. + $schema->addBaseValidator( + ($validator instanceof ComposedPropertyValidator) + ? $validator->withoutNestedCompositionValidation() + : $validator, + ); + + if (!is_a($validator->getCompositionProcessor(), ComposedPropertiesInterface::class, true)) { + continue; + } + + $branchesForValidator = $validator instanceof ConditionalPropertyValidator + ? $validator->getConditionBranches() + : $validator->getComposedProperties(); + + $totalBranches = count($branchesForValidator); + $resolvedPropertiesCallbacks = 0; + $seenBranchPropertyNames = []; + + foreach ($validator->getComposedProperties() as $composedProperty) { + $composedProperty->onResolve(function () use ( + $composedProperty, + $property, + $validator, + $branchesForValidator, + $totalBranches, + $schema, + &$resolvedPropertiesCallbacks, + &$seenBranchPropertyNames, + ): void { + if (!$composedProperty->getNestedSchema()) { + throw new SchemaException( + sprintf( + "No nested schema for composed property %s in file %s found", + $property->getName(), + $property->getJsonSchema()->getFile(), + ) + ); + } + + $isBranchForValidator = in_array($composedProperty, $branchesForValidator, true); + + $composedProperty->getNestedSchema()->onAllPropertiesResolved( + function () use ( + $composedProperty, + $validator, + $isBranchForValidator, + $totalBranches, + $schema, + &$resolvedPropertiesCallbacks, + &$seenBranchPropertyNames, + ): void { + foreach ($composedProperty->getNestedSchema()->getProperties() as $branchProperty) { + $schema->addProperty( + $this->cloneTransferredProperty( + $branchProperty, + $composedProperty, + $validator, + ), + $validator->getCompositionProcessor(), + ); + + $composedProperty->appendAffectedObjectProperty($branchProperty); + $seenBranchPropertyNames[$branchProperty->getName()] = true; + } + + if ($isBranchForValidator && ++$resolvedPropertiesCallbacks === $totalBranches) { + foreach (array_keys($seenBranchPropertyNames) as $branchPropertyName) { + $schema->getPropertyMerger()->checkForTotalConflict( + $branchPropertyName, + $totalBranches, + ); + } + } + }, + ); + }); + } + } + } + + /** + * Clone the provided property to transfer it to a schema. Sets the nullability and required + * flag based on the composition processor used to set up the composition. Widens the type to + * mixed when the property is exclusive to one anyOf/oneOf branch and at least one other branch + * allows additional properties, preventing TypeError when raw input values of an arbitrary + * type are stored in the property slot. + */ + private function cloneTransferredProperty( + PropertyInterface $property, + CompositionPropertyDecorator $sourceBranch, + AbstractComposedPropertyValidator $validator, + ): PropertyInterface { + $compositionProcessor = $validator->getCompositionProcessor(); + + $transferredProperty = (clone $property) + ->filterValidators(static fn(Validator $v): bool => + is_a($v->getValidator(), PropertyTemplateValidator::class)); + + if (!is_a($compositionProcessor, AllOfProcessor::class, true)) { + $transferredProperty->setRequired(false); + + if ($transferredProperty->getType()) { + $transferredProperty->setType( + new PropertyType($transferredProperty->getType()->getNames(), true), + new PropertyType($transferredProperty->getType(true)->getNames(), true), + ); + } + + $wideningBranches = $validator instanceof ConditionalPropertyValidator + ? $validator->getConditionBranches() + : $validator->getComposedProperties(); + + if ($this->exclusiveBranchPropertyNeedsWidening($property->getName(), $sourceBranch, $wideningBranches)) { + $transferredProperty->setType(null, null, reset: true); + } + } + + return $transferredProperty; + } + + /** + * Returns true when the property named $propertyName is exclusive to $sourceBranch and at + * least one other anyOf/oneOf branch allows additional properties (i.e. does NOT declare + * additionalProperties: false). In that case the property slot can receive an + * arbitrarily-typed raw input value from a non-matching branch, so the type hint is removed. + * + * Returns false when the property appears in another branch too (Schema::addProperty handles + * that via type merging) or when all other branches have additionalProperties: false (making + * the property mutually exclusive with the other branches' properties). + * + * @param CompositionPropertyDecorator[] $allBranches + */ + private function exclusiveBranchPropertyNeedsWidening( + string $propertyName, + CompositionPropertyDecorator $sourceBranch, + array $allBranches, + ): bool { + foreach ($allBranches as $branch) { + if ($branch === $sourceBranch) { + continue; + } + + $branchPropertyNames = $branch->getNestedSchema() + ? array_map( + static fn(PropertyInterface $p): string => $p->getName(), + $branch->getNestedSchema()->getProperties(), + ) + : []; + + if (in_array($propertyName, $branchPropertyNames, true)) { + return false; + } + } + + foreach ($allBranches as $branch) { + if ($branch === $sourceBranch) { + continue; + } + + if (($branch->getBranchSchema()->getJson()['additionalProperties'] ?? true) !== false) { + return true; + } + } + + return false; + } + private function getTargetFileName(string $classPath, string $className): string { return join( From f104e7770786133e7f1a4f393d8cccfbb56c3d75 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Sun, 29 Mar 2026 04:39:20 +0200 Subject: [PATCH 7/9] Implement Phase 7: composition validator factories; eliminate ComposedValueProcessorFactory Introduce Model\Validator\Factory\Composition\AbstractCompositionValidatorFactory (extends AbstractValidatorFactory) with shared composition helpers, plus five concrete factories: AllOfValidatorFactory, AnyOfValidatorFactory, OneOfValidatorFactory, NotValidatorFactory, IfValidatorFactory. A marker interface ComposedPropertiesValidatorFactoryInterface replaces ComposedPropertiesInterface for the property-transfer guard. Register all five on the 'any' type in Draft_07 via addValidator(), consistent with the keyword-keyed pattern established in Phase 4. Delete ComposedValueProcessorFactory and all legacy ComposedValue processor classes (AbstractComposedValueProcessor, AllOfProcessor, AnyOfProcessor, OneOfProcessor, NotProcessor, IfProcessor and their interfaces). AbstractPropertyProcessor is slimmed to only the RequiredPropertyValidator and isImplicitNullAllowed helpers still needed by the bridge. Update all is_a() checks and use-imports in Schema, BaseProcessor, SchemaProcessor, ConditionalPropertyValidator, and CompositionRequiredPromotionPostProcessor to reference the new factory classes. --- .../implementation-plan.md | 208 +++++++++++---- src/Draft/Draft_07.php | 10 + src/Model/Schema.php | 4 +- .../Validator/ComposedPropertyValidator.php | 20 +- .../ConditionalPropertyValidator.php | 4 +- .../AbstractCompositionValidatorFactory.php | 222 ++++++++++++++++ .../Composition/AllOfValidatorFactory.php | 104 ++++++++ .../Composition/AnyOfValidatorFactory.php | 108 ++++++++ ...sedPropertiesValidatorFactoryInterface.php | 16 ++ .../Composition/IfValidatorFactory.php | 113 ++++++++ .../Composition/NotValidatorFactory.php | 82 ++++++ .../Composition/OneOfValidatorFactory.php | 90 +++++++ .../AbstractComposedValueProcessor.php | 245 ------------------ .../ComposedValue/AllOfProcessor.php | 23 -- .../ComposedValue/AnyOfProcessor.php | 23 -- .../ComposedPropertiesInterface.php | 14 - .../ComposedValue/IfProcessor.php | 99 ------- .../MergedComposedPropertiesInterface.php | 14 - .../ComposedValue/NotProcessor.php | 47 ---- .../ComposedValue/OneOfProcessor.php | 21 -- .../ComposedValueProcessorFactory.php | 48 ---- .../Property/AbstractPropertyProcessor.php | 113 -------- .../Property/BaseProcessor.php | 14 +- ...positionRequiredPromotionPostProcessor.php | 4 +- src/SchemaProcessor/SchemaProcessor.php | 14 +- 25 files changed, 929 insertions(+), 731 deletions(-) create mode 100644 src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Composition/ComposedPropertiesValidatorFactoryInterface.php create mode 100644 src/Model/Validator/Factory/Composition/IfValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Composition/NotValidatorFactory.php create mode 100644 src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php delete mode 100644 src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php delete mode 100644 src/PropertyProcessor/ComposedValue/AllOfProcessor.php delete mode 100644 src/PropertyProcessor/ComposedValue/AnyOfProcessor.php delete mode 100644 src/PropertyProcessor/ComposedValue/ComposedPropertiesInterface.php delete mode 100644 src/PropertyProcessor/ComposedValue/IfProcessor.php delete mode 100644 src/PropertyProcessor/ComposedValue/MergedComposedPropertiesInterface.php delete mode 100644 src/PropertyProcessor/ComposedValue/NotProcessor.php delete mode 100644 src/PropertyProcessor/ComposedValue/OneOfProcessor.php delete mode 100644 src/PropertyProcessor/ComposedValueProcessorFactory.php diff --git a/.claude/topics/reworkstructure-analysis/implementation-plan.md b/.claude/topics/reworkstructure-analysis/implementation-plan.md index 8a868bcd..ec6a3122 100644 --- a/.claude/topics/reworkstructure-analysis/implementation-plan.md +++ b/.claude/topics/reworkstructure-analysis/implementation-plan.md @@ -477,7 +477,7 @@ No user-visible behaviour change. No doc updates needed this phase. --- -## Phase 5 — Eliminate `MultiTypeProcessor` +## Phase 5 — Eliminate `MultiTypeProcessor` **[DONE — commit 997b639]** **Goal**: `"type": ["string","null"]` is handled by `PropertyFactory` directly — it iterates the type list, processes each type through the legacy per-type processor to collect validators @@ -554,56 +554,162 @@ Delete: ## Phase 6 — `ObjectProcessor` as modifier; `object` modifier list complete -**Goal**: The `'object'` type modifier list in `Draft07` now includes an `ObjectModifier` that -handles nested-object instantiation (what `ObjectProcessor::process` does today) in addition to -the keyword modifiers from Phase 4. `ObjectProcessor` is then deprecated and the legacy bridge -for `type=object` is removed. +**Goal**: The `'object'` type modifier list in `Draft07` is completed with all object keyword +modifiers (originally scoped to Phase 4 but deferred — see note below) plus a new `ObjectModifier` +that handles nested-object instantiation (what `ObjectProcessor::process` does today). The +`BaseProcessor` keyword methods are also migrated to modifiers so the `type=base` path runs +entirely through `PropertyFactory` → Draft modifiers. `ObjectProcessor` and `BaseProcessor` are +then deprecated and the legacy bridge for `type=object`/`type=base` is removed. + +**Note on Phase 4 omission**: The object keyword validator factories were listed in Phase 4's scope +but were not implemented in that phase. They are implemented here in Phase 6 alongside the +`ObjectModifier`. Unlike scalar keyword factories (which extend `AbstractValidatorFactory`), the +object keyword handlers are too complex for `SimplePropertyValidatorFactory` and are implemented +as `ModifierInterface` classes directly (following the `*Modifier` naming convention). + +**Architectural decision — single `object` type entry**: The Draft does not differentiate between +`type=base` (root class) and `type=object` (nested property). A single `'object'` entry in +`Draft_07` holds ALL object modifiers — keyword modifiers and `ObjectModifier` — because the same +JSON Schema keywords (`properties`, `minProperties`, `additionalProperties`, etc.) apply to every +object-typed schema regardless of context. This also preserves the ability for users to attach +custom modifiers to the `object` type cleanly. + +The key to making this work: keyword modifiers always add validators/properties to `$schema`. +For `type=base`, `$schema` is the class being built — correct. For `type=object` nested +properties, `PropertyFactory` resolves the nested Schema first (via `processSchema`), then passes +that nested Schema as `$schema` when invoking the Draft modifiers. `ObjectModifier` then wires +the instantiation linkage (`ObjectInstantiationDecorator`, `InstanceOfValidator`, `setType`, +`setNestedSchema`) on the outer property. Result: same modifier list, always correct `$schema`. + +### 6.1 — Object keyword modifiers + +New files in `src/Draft/Modifier/ObjectType/`: + +- **`PropertiesModifier`** — extracts `BaseProcessor::addPropertiesToSchema`. Reads + `$json['properties']` (and fills in entries for undeclared-but-required properties), then for + each property calls `PropertyFactory::create` and `$schema->addProperty`. Also calls + `addDependencyValidator` for properties that have a dependency. The `addDependencyValidator` + helper is lifted out of `BaseProcessor` into a private method on `PropertiesModifier`. +- **`PropertyNamesModifier`** — extracts `BaseProcessor::addPropertyNamesValidator`. Reads + `$json['propertyNames']` and adds a `PropertyNamesValidator` to `$schema`. +- **`PatternPropertiesModifier`** — extracts `BaseProcessor::addPatternPropertiesValidator`. + Reads `$json['patternProperties']` and adds `PatternPropertiesValidator` instances to `$schema`. +- **`AdditionalPropertiesModifier`** — extracts `BaseProcessor::addAdditionalPropertiesValidator`. + Reads `$json['additionalProperties']` (and the generator config's `denyAdditionalProperties`) + and adds `AdditionalPropertiesValidator` or `NoAdditionalPropertiesValidator` to `$schema`. +- **`MinPropertiesModifier`** / **`MaxPropertiesModifier`** — extract + `BaseProcessor::addMinPropertiesValidator` / `addMaxPropertiesValidator`. Each adds a + `PropertyValidator` to `$schema`'s base validators. + +The dependency-related helpers (`addDependencyValidator`, `transferDependentPropertiesToBaseSchema`) +that currently live in `BaseProcessor` move into `PropertiesModifier` as private methods. + +All modifiers call `$schema->addBaseValidator(...)` / `$schema->addProperty(...)`, exactly as +`BaseProcessor` does today. + +### 6.2 — `ObjectModifier` + +New file `src/Draft/Modifier/ObjectType/ObjectModifier.php` — wires the instantiation linkage +for `type=object` properties: +- Adds `ObjectInstantiationDecorator`, `InstanceOfValidator`, sets `PropertyType` to class name, + sets `nestedSchema` on the property +- Handles namespace transfer (adds `usedClass` and `SchemaNamespaceTransferDecorator` when + the nested schema lives in a different namespace) +- **Does NOT call `processSchema`** — the nested Schema is already available on the property + via `$property->getNestedSchema()`, set before the modifier list runs (see 6.4) +- Skips when `$property instanceof BaseProperty` (root schema — no instantiation needed) + +### 6.3 — Register in `Draft_07` + +Register all object modifiers on the `object` type in `Draft_07::getDefinition()`: -### 6.1 — `ObjectModifier` +```php +(new Type('object', false)) + ->addModifier(new PropertiesModifier()) + ->addModifier(new PropertyNamesModifier()) + ->addModifier(new PatternPropertiesModifier()) + ->addModifier(new AdditionalPropertiesModifier()) + ->addModifier(new MinPropertiesModifier()) + ->addModifier(new MaxPropertiesModifier()) + ->addModifier(new ObjectModifier()) +``` -New file `src/Draft/Modifier/ObjectType/ObjectModifier.php` — extracts the nested schema -processing logic from `ObjectProcessor::process`: -- Calls `$schemaProcessor->processSchema(...)` to generate the nested class -- Adds `ObjectInstantiationDecorator`, `InstanceOfValidator`, sets `PropertyType` to class name -- Handles namespace transfer +(`addModifier` is the appropriate call since these are `ModifierInterface` implementations +directly, not `AbstractValidatorFactory` subclasses.) -### 6.2 — Remove legacy bridge for `type=object` +### 6.4 — `PropertyFactory` handling of `type=object` and `type=base` -Once `ObjectModifier` is registered in `Draft07`'s `object` modifier list and the Phase 4 -object keyword modifiers are also there, `ObjectProcessor` can be deprecated. +The trick that makes the single `object` modifier list work for both contexts: `PropertyFactory` +**resolves the nested Schema before running modifiers**, then passes that nested Schema as +`$schema` when invoking `applyDraftModifiers`. Keyword modifiers always add to `$schema` — +which is now the correct target in both cases. -### 6.3 — `BaseProcessor` pipeline step +**For `type=base` (root schema):** +``` +SchemaProcessor::generateModel + → PropertyFactory::create(type=base) + → construct BaseProperty + → applyDraftModifiers(schemaProcessor, schema=$outerSchema, property, propertySchema) + → PropertiesModifier — adds to $outerSchema ✓ (it IS the class being built) + → PropertyNamesModifier — adds to $outerSchema ✓ + → … other keyword modifiers … + → ObjectModifier — skips (instanceof BaseProperty) +``` -`BaseProcessor` handles the root-level object (`type=base`). Its pipeline steps are: -1. `setUpDefinitionDictionary` — stays in `SchemaProcessor` (internal mechanic) -2. All `object` keyword modifiers (now in `Draft07`) — called by `PropertyFactory` -3. `transferComposedPropertiesToSchema` — stays as explicit post-step in `SchemaProcessor` +**For `type=object` (nested property):** +``` +PropertyFactory::create(type=object) + → construct Property + → call processSchema → returns nestedSchema + → store nestedSchema on property (property->setNestedSchema(nestedSchema)) + → applyDraftModifiers(schemaProcessor, schema=$nestedSchema, property, propertySchema) + → PropertiesModifier — adds to $nestedSchema ✓ + → PropertyNamesModifier — adds to $nestedSchema ✓ + → … other keyword modifiers … + → ObjectModifier — wires ObjectInstantiationDecorator, InstanceOfValidator, setType + on the outer property using the already-set nestedSchema ✓ +``` -`SchemaProcessor::generateModel` calls `PropertyFactory::create` with `type=base`, which the -factory translates to constructing a `BaseProperty` and running the `object` modifier list. -`type=base` is no longer a type string in the Draft — `PropertyFactory` detects it as the -special root-schema signal: +`processSchema` is called from `PropertyFactory` before `applyDraftModifiers`. This replaces +what `ObjectProcessor::process` previously did. `ObjectModifier` no longer calls `processSchema` +at all — it only reads `$property->getNestedSchema()`. -```php -if ($json['type'] === 'base') { - $property = new BaseProperty($propertyName, new PropertyType('object'), $propertySchema); - $types = ['object']; -} else { - $property = new Property(...); - $types = $this->resolveTypes($json); -} -``` +**For `type=base`**, `applyDraftModifiers` is called with the existing outer `$schema` (unchanged +from before). `PropertyFactory` does NOT call `processSchema` for `type=base` — `generateModel` +already created the Schema before calling `PropertyFactory::create`. + +The `type=base` path in `PropertyFactory` still routes through the legacy `BaseProcessor` for +the bridge period (composition/required validators). The object keyword modifiers run via +`applyDraftModifiers` AFTER the legacy processor, with dedup guards in the legacy processor's +keyword methods to skip if the modifier already added the validator. + +**Simpler chosen approach**: remove the legacy bridge for `type=object` immediately (clean cut, +no dedup needed). For `type=base`, keep `BaseProcessor` as the bridge but bypass its keyword +methods since `applyDraftModifiers` now handles them. `BaseProcessor::process` is updated to +skip `addPropertiesToSchema`, `addPropertyNamesValidator`, `addPatternPropertiesValidator`, +`addAdditionalPropertiesValidator`, `addMinPropertiesValidator`, `addMaxPropertiesValidator` — +these are now handled by Draft modifiers. It retains only `setUpDefinitionDictionary`, +`generateValidators` (composition), and `transferComposedPropertiesToSchema`. + +### 6.5 — Deprecate `ObjectProcessor` and `BaseProcessor` -This is the minimal special-casing agreed in Q2.1 (Option A from the analysis). +Once the modifiers are in place and the legacy bridges are updated: +- Mark `ObjectProcessor` as `@deprecated` (deletion in Phase 8) +- Mark `BaseProcessor` as `@deprecated` (deletion in Phase 8) +- `PropertyProcessorFactory` no longer routes `object` to a legacy processor +- `base` still routes to the stripped-down `BaseProcessor` bridge (composition only) ### Tests for Phase 6 - All object-property and nested-object integration tests must stay green. - `ObjectPropertyTest`, `IdenticalNestedSchemaTest`, `ReferencePropertyTest`. +- All property-level tests for `properties`, `propertyNames`, `patternProperties`, + `additionalProperties`, `minProperties`, `maxProperties` must stay green. +- `BaseProcessor`-related tests (object schema tests) must stay green. --- -## Phase 7 — `CompositionModifier`; eliminate `ComposedValueProcessorFactory` +## Phase 7 — Composition validator factories; eliminate `ComposedValueProcessorFactory` **[DONE]** **Goal**: Replace `AbstractComposedValueProcessor` and `ComposedValueProcessorFactory` with a single `CompositionModifier` universal modifier. This is the highest-risk phase. @@ -625,29 +731,27 @@ All PMC-mutation workarounds were **fully resolved in Phase 2**: It propagates correctly via `$property->isRequired()` in `getCompositionProperties`. No change is needed here in Phase 7. -### 7.1 — `CompositionModifier` +### 7.1 — Composition validator factories -New file `src/Draft/Modifier/CompositionModifier.php`. Extracts the logic from -`AbstractPropertyProcessor::addComposedValueValidator`: -- Iterates `['allOf','anyOf','oneOf','not','if']` keywords -- Uses `$property instanceof BaseProperty` to determine root-level (replacing `rootLevelComposition`) -- Creates `CompositionPropertyDecorator` instances, sets up `onResolve` callbacks, - emits `ComposedPropertyValidator` -- Calls `SchemaProcessor::createMergedProperty` for non-root non-allOf compositions -- Handles `not`-keyword strict-null enforcement **without** mutating `isRequired` on the - composition property — instead passes an explicit `allowImplicitNull=false` signal through the - sub-property creation API (see bridge-period debt above). +New files in `src/Model/Validator/Factory/Composition/`: +- `AbstractCompositionValidatorFactory` — extends `AbstractValidatorFactory`; shared helpers + (`warnIfEmpty`, `shouldSkip`, `getCompositionProperties`, `inheritPropertyType`, + `transferPropertyType`). The `$this->key` (set by `Type::addValidator`) is the composition + keyword (`allOf`, `anyOf`, etc.). +- `ComposedPropertiesValidatorFactoryInterface` — marker interface for factories that transfer + properties to the parent schema (all except `NotValidatorFactory`). +- `AllOfValidatorFactory`, `AnyOfValidatorFactory`, `OneOfValidatorFactory`, + `NotValidatorFactory`, `IfValidatorFactory` — each implements `modify()` directly. -The `AbstractComposedValueProcessor` subclasses (`AllOfProcessor`, `AnyOfProcessor`, -`OneOfProcessor`, `NotProcessor`, `IfProcessor`) are replaced by the logic inside -`CompositionModifier` (using `match($keyword)` or strategy objects for the -`getComposedValueValidation` difference between allOf/anyOf/oneOf/not/if). +All registered on the `'any'` type in `Draft_07` via `addValidator('allOf', ...)` etc. +`ComposedPropertyValidator` and `ConditionalPropertyValidator` store `static::class` of the +factory as the composition processor string; all `is_a()` checks updated accordingly. ### 7.2 — `ComposedValueProcessorFactory` deletion -Once `CompositionModifier` is in the universal modifier list of `Draft07` and handles both -root-level and property-level composition, `ComposedValueProcessorFactory` and the -composition processor classes are deleted. +`ComposedValueProcessorFactory` and the composition processor classes were already deleted in +the prior commit (git status showed them as `D`). The validator factories are the sole +composition mechanism. ### 7.3 — `ConstProcessor` and `ReferenceProcessor` diff --git a/src/Draft/Draft_07.php b/src/Draft/Draft_07.php index 4f0cd9e4..1d6b3e30 100644 --- a/src/Draft/Draft_07.php +++ b/src/Draft/Draft_07.php @@ -6,6 +6,11 @@ use PHPModelGenerator\Draft\Element\Type; use PHPModelGenerator\Draft\Modifier\DefaultArrayToEmptyArrayModifier; +use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Composition\AnyOfValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Composition\IfValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Composition\NotValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Composition\OneOfValidatorFactory; use PHPModelGenerator\Draft\Modifier\DefaultValueModifier; use PHPModelGenerator\Draft\Modifier\ObjectType\ObjectModifier; use PHPModelGenerator\Model\Validator\Factory\Any\EnumValidatorFactory; @@ -73,6 +78,11 @@ public function getDefinition(): DraftBuilder ->addType((new Type('any', false)) ->addValidator('enum', new EnumValidatorFactory()) ->addValidator('filter', new FilterValidatorFactory()) + ->addValidator('allOf', new AllOfValidatorFactory()) + ->addValidator('anyOf', new AnyOfValidatorFactory()) + ->addValidator('oneOf', new OneOfValidatorFactory()) + ->addValidator('not', new NotValidatorFactory()) + ->addValidator('if', new IfValidatorFactory()) ->addModifier(new DefaultValueModifier())); } } diff --git a/src/Model/Schema.php b/src/Model/Schema.php index ee837dee..ebb1b875 100644 --- a/src/Model/Schema.php +++ b/src/Model/Schema.php @@ -13,7 +13,7 @@ use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; use PHPModelGenerator\Model\Validator\PropertyValidatorInterface; use PHPModelGenerator\Model\Validator\SchemaDependencyValidator; -use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor; +use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookInterface; use PHPModelGenerator\Utils\PropertyMerger; @@ -170,7 +170,7 @@ public function addProperty(PropertyInterface $property, ?string $compositionPro $this->propertyMerger->merge( $this->properties[$property->getName()], $property, - is_a($compositionProcessor, AllOfProcessor::class, true), + is_a($compositionProcessor, AllOfValidatorFactory::class, true), ); return $this; diff --git a/src/Model/Validator/ComposedPropertyValidator.php b/src/Model/Validator/ComposedPropertyValidator.php index a1011f4f..fa074662 100644 --- a/src/Model/Validator/ComposedPropertyValidator.php +++ b/src/Model/Validator/ComposedPropertyValidator.php @@ -4,7 +4,6 @@ namespace PHPModelGenerator\Model\Validator; -use PHPModelGenerator\Exception\ComposedValue\InvalidComposedValueException; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Model\MethodInterface; use PHPModelGenerator\Model\Property\CompositionPropertyDecorator; @@ -25,6 +24,7 @@ public function __construct( PropertyInterface $property, array $composedProperties, string $compositionProcessor, + string $exceptionClass, array $validatorVariables, ) { $this->modifiedValuesMethod = '_getModifiedValues_' . substr(md5(spl_object_hash($this)), 0, 5); @@ -35,7 +35,7 @@ public function __construct( $property, DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ComposedItem.phptpl', array_merge($validatorVariables, ['modifiedValuesMethod' => $this->modifiedValuesMethod]), - $this->getExceptionByProcessor($compositionProcessor), + $exceptionClass, ['&$succeededCompositionElements', '&$compositionErrorCollection'], ); @@ -141,20 +141,4 @@ public function withoutNestedCompositionValidation(): self return $validator; } - - /** - * Parse the composition type (allOf, anyOf, ...) from the given processor and get the corresponding exception class - */ - private function getExceptionByProcessor(string $compositionProcessor): string - { - return str_replace( - DIRECTORY_SEPARATOR, - '\\', - dirname(str_replace('\\', DIRECTORY_SEPARATOR, InvalidComposedValueException::class)), - ) . '\\' . str_replace( - 'Processor', - '', - substr($compositionProcessor, strrpos($compositionProcessor, '\\') + 1), - ) . 'Exception'; - } } diff --git a/src/Model/Validator/ConditionalPropertyValidator.php b/src/Model/Validator/ConditionalPropertyValidator.php index 44667125..c256d43d 100644 --- a/src/Model/Validator/ConditionalPropertyValidator.php +++ b/src/Model/Validator/ConditionalPropertyValidator.php @@ -8,7 +8,7 @@ use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Model\Property\CompositionPropertyDecorator; use PHPModelGenerator\Model\Property\PropertyInterface; -use PHPModelGenerator\PropertyProcessor\ComposedValue\IfProcessor; +use PHPModelGenerator\Model\Validator\Factory\Composition\IfValidatorFactory; /** * Class ConditionalPropertyValidator @@ -38,7 +38,7 @@ public function __construct( ['&$ifException', '&$thenException', '&$elseException'], ); - $this->compositionProcessor = IfProcessor::class; + $this->compositionProcessor = IfValidatorFactory::class; $this->composedProperties = $composedProperties; $this->conditionBranches = $conditionBranches; } diff --git a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php new file mode 100644 index 00000000..9fb62e0d --- /dev/null +++ b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php @@ -0,0 +1,222 @@ +getJson()[$this->key]) && + $schemaProcessor->getGeneratorConfiguration()->isOutputEnabled() + ) { + // @codeCoverageIgnoreStart + echo "Warning: empty composition for {$property->getName()} may lead to unexpected results\n"; + // @codeCoverageIgnoreEnd + } + } + + /** + * Returns true when composition processing should be skipped for this property. + * + * For non-root object-typed properties, composition keywords are processed inside + * the nested schema by processSchema (with the type=base path). Adding a composition + * validator at the parent level would duplicate validation and inject a _Merged_ type + * hint that overrides the correct nested-class type. + */ + protected function shouldSkip(PropertyInterface $property, JsonSchema $propertySchema): bool + { + return !($property instanceof BaseProperty) + && ($propertySchema->getJson()['type'] ?? '') === 'object'; + } + + /** + * Build composition sub-properties for the current keyword's branches. + * + * @param bool $merged Whether to suppress CompositionTypeHintDecorators for object branches. + * + * @return CompositionPropertyDecorator[] + * + * @throws SchemaException + */ + protected function getCompositionProperties( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + bool $merged, + ): array { + $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); + $compositionProperties = []; + $json = $propertySchema->getJson()['propertySchema']->getJson(); + + $property->addTypeHintDecorator(new ClearTypeHintDecorator()); + + foreach ($json[$this->key] as $compositionElement) { + $compositionSchema = $propertySchema->getJson()['propertySchema']->withJson($compositionElement); + + $compositionProperty = new CompositionPropertyDecorator( + $property->getName(), + $compositionSchema, + $propertyFactory->create( + $schemaProcessor, + $schema, + $property->getName(), + $compositionSchema, + $property->isRequired(), + ), + ); + + $compositionProperty->onResolve(function () use ($compositionProperty, $property, $merged): void { + $compositionProperty->filterValidators( + static fn(Validator $validator): bool => + !is_a($validator->getValidator(), RequiredPropertyValidator::class) && + !is_a($validator->getValidator(), ComposedPropertyValidator::class), + ); + + if (!($merged && $compositionProperty->getNestedSchema())) { + $property->addTypeHintDecorator(new CompositionTypeHintDecorator($compositionProperty)); + } + }); + + $compositionProperties[] = $compositionProperty; + } + + return $compositionProperties; + } + + /** + * Inherit a parent-level type into composition branches that declare no type. + */ + protected function inheritPropertyType(JsonSchema $propertySchema): JsonSchema + { + $json = $propertySchema->getJson(); + + if (!isset($json['type'])) { + return $propertySchema; + } + + if ($json['type'] === 'base') { + $json['type'] = 'object'; + } + + switch ($this->key) { + case 'not': + if (!isset($json[$this->key]['type'])) { + $json[$this->key]['type'] = $json['type']; + } + break; + case 'if': + return $this->inheritIfPropertyType($propertySchema->withJson($json)); + default: + foreach ($json[$this->key] as &$composedElement) { + if (!isset($composedElement['type'])) { + $composedElement['type'] = $json['type']; + } + } + } + + return $propertySchema->withJson($json); + } + + /** + * Inherit the parent type into all branches of an if/then/else composition. + */ + protected function inheritIfPropertyType(JsonSchema $propertySchema): JsonSchema + { + $json = $propertySchema->getJson(); + + foreach (['if', 'then', 'else'] as $keyword) { + if (!isset($json[$keyword])) { + continue; + } + + if (!isset($json[$keyword]['type'])) { + $json[$keyword]['type'] = $json['type']; + } + } + + return $propertySchema->withJson($json); + } + + /** + * After all composition branches resolve, attempt to widen the parent property's type + * to cover all branch types. Skips for branches with nested schemas. + * + * @param bool $isAllOf Whether allOf semantics apply (affects nullable detection). + * @param CompositionPropertyDecorator[] $compositionProperties + */ + protected function transferPropertyType( + PropertyInterface $property, + array $compositionProperties, + bool $isAllOf, + ): void { + foreach ($compositionProperties as $compositionProperty) { + if ($compositionProperty->getNestedSchema() !== null) { + return; + } + } + + $allNames = array_merge(...array_map( + static fn(CompositionPropertyDecorator $p): array => + $p->getType() ? $p->getType()->getNames() : [], + $compositionProperties, + )); + + $hasBranchWithNoType = array_filter( + $compositionProperties, + static fn(CompositionPropertyDecorator $p): bool => $p->getType() === null, + ) !== []; + + $hasBranchWithRequiredProperty = array_filter( + $compositionProperties, + static fn(CompositionPropertyDecorator $p): bool => $p->isRequired(), + ) !== []; + + $hasBranchWithOptionalProperty = $isAllOf + ? !$hasBranchWithRequiredProperty + : array_filter( + $compositionProperties, + static fn(CompositionPropertyDecorator $p): bool => !$p->isRequired(), + ) !== []; + + $hasNull = in_array('null', $allNames, true); + $nonNullNames = array_values(array_filter( + array_unique($allNames), + fn(string $t): bool => $t !== 'null', + )); + + if (!$nonNullNames) { + return; + } + + $nullable = ($hasNull || $hasBranchWithNoType || $hasBranchWithOptionalProperty) ? true : null; + + $property->setType(new PropertyType($nonNullNames, $nullable)); + } +} diff --git a/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php new file mode 100644 index 00000000..9b76ec2e --- /dev/null +++ b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php @@ -0,0 +1,104 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); + $propertySchema = $this->inheritPropertyType($propertySchema); + + $wrappedSchema = $propertySchema->withJson([ + 'type' => $this->key, + 'propertySchema' => $propertySchema, + 'onlyForDefinedValues' => false, + ]); + + $compositionProperties = $this->getCompositionProperties( + $schemaProcessor, + $schema, + $property, + $wrappedSchema, + true, + ); + + $resolvedCompositions = 0; + $mergedProperty = null; + foreach ($compositionProperties as $compositionProperty) { + $compositionProperty->onResolve( + function () use ( + &$resolvedCompositions, + &$mergedProperty, + $property, + $compositionProperties, + $wrappedSchema, + $schemaProcessor, + $schema, + ): void { + if (++$resolvedCompositions === count($compositionProperties)) { + $this->transferPropertyType($property, $compositionProperties, true); + + $mergedProperty = !($property instanceof BaseProperty) + ? $schemaProcessor->createMergedProperty( + $schema, + $property, + $compositionProperties, + $wrappedSchema, + ) + : null; + } + }, + ); + } + + $availableAmount = count($compositionProperties); + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + AllOfException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => $availableAmount, + 'composedValueValidation' => "\$succeededCompositionElements === $availableAmount", + 'postPropose' => true, + 'mergedProperty' => &$mergedProperty, + 'onlyForDefinedValues' => false, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php new file mode 100644 index 00000000..33c25085 --- /dev/null +++ b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php @@ -0,0 +1,108 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); + $propertySchema = $this->inheritPropertyType($propertySchema); + + $onlyForDefinedValues = !($property instanceof BaseProperty) + && (!$property->isRequired() + && $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()); + + $wrappedSchema = $propertySchema->withJson([ + 'type' => $this->key, + 'propertySchema' => $propertySchema, + 'onlyForDefinedValues' => $onlyForDefinedValues, + ]); + + $compositionProperties = $this->getCompositionProperties( + $schemaProcessor, + $schema, + $property, + $wrappedSchema, + true, + ); + + $resolvedCompositions = 0; + $mergedProperty = null; + foreach ($compositionProperties as $compositionProperty) { + $compositionProperty->onResolve( + function () use ( + &$resolvedCompositions, + &$mergedProperty, + $property, + $compositionProperties, + $wrappedSchema, + $schemaProcessor, + $schema, + ): void { + if (++$resolvedCompositions === count($compositionProperties)) { + $this->transferPropertyType($property, $compositionProperties, false); + + $mergedProperty = !($property instanceof BaseProperty) + ? $schemaProcessor->createMergedProperty( + $schema, + $property, + $compositionProperties, + $wrappedSchema, + ) + : null; + } + }, + ); + } + + $availableAmount = count($compositionProperties); + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + AnyOfException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => $availableAmount, + 'composedValueValidation' => '$succeededCompositionElements > 0', + 'postPropose' => true, + 'mergedProperty' => &$mergedProperty, + 'onlyForDefinedValues' => $onlyForDefinedValues, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/ComposedPropertiesValidatorFactoryInterface.php b/src/Model/Validator/Factory/Composition/ComposedPropertiesValidatorFactoryInterface.php new file mode 100644 index 00000000..fb29fc5a --- /dev/null +++ b/src/Model/Validator/Factory/Composition/ComposedPropertiesValidatorFactoryInterface.php @@ -0,0 +1,16 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + $json = $propertySchema->getJson(); + + if (!isset($json['then']) && !isset($json['else'])) { + throw new SchemaException( + sprintf( + 'Incomplete conditional composition for property %s in file %s', + $property->getName(), + $property->getJsonSchema()->getFile(), + ), + ); + } + + $propertySchema = $this->inheritPropertyType($propertySchema); + $json = $propertySchema->getJson(); + + $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); + + $onlyForDefinedValues = !($property instanceof BaseProperty) + && (!$property->isRequired() + && $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()); + + /** @var array $properties */ + $properties = []; + + foreach (['if', 'then', 'else'] as $keyword) { + if (!isset($json[$keyword])) { + $properties[$keyword] = null; + continue; + } + + $compositionSchema = $propertySchema->withJson($json[$keyword]); + + $compositionProperty = new CompositionPropertyDecorator( + $property->getName(), + $compositionSchema, + $propertyFactory->create( + $schemaProcessor, + $schema, + $property->getName(), + $compositionSchema, + $property->isRequired(), + ), + ); + + $compositionProperty->onResolve(static function () use ($compositionProperty): void { + $compositionProperty->filterValidators( + static fn(Validator $validator): bool => + !is_a($validator->getValidator(), RequiredPropertyValidator::class) && + !is_a($validator->getValidator(), ComposedPropertyValidator::class), + ); + }); + + $properties[$keyword] = $compositionProperty; + } + + $property->addValidator( + new ConditionalPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + array_values(array_filter($properties)), + array_values(array_filter([$properties['then'], $properties['else']])), + [ + 'ifProperty' => $properties['if'], + 'thenProperty' => $properties['then'], + 'elseProperty' => $properties['else'], + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'onlyForDefinedValues' => $onlyForDefinedValues, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/NotValidatorFactory.php b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php new file mode 100644 index 00000000..2f240535 --- /dev/null +++ b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php @@ -0,0 +1,82 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + // Inherit the parent type into the not branch before wrapping in array. + // inheritPropertyType for 'not' treats $json['not'] as a single schema object, + // so it must run before we wrap it in an array for iteration. + $propertySchema = $this->inheritPropertyType($propertySchema); + $json = $propertySchema->getJson(); + + // Wrap the single 'not' schema in an array so getCompositionProperties can iterate it. + $json[$this->key] = [$json[$this->key]]; + $wrappedOuter = $propertySchema->withJson($json); + + // Force required=true so strict null checks apply inside the not branch. + $property->setRequired(true); + + $wrappedSchema = $wrappedOuter->withJson([ + 'type' => $this->key, + 'propertySchema' => $wrappedOuter, + 'onlyForDefinedValues' => false, + ]); + + $compositionProperties = $this->getCompositionProperties( + $schemaProcessor, + $schema, + $property, + $wrappedSchema, + false, + ); + + $availableAmount = count($compositionProperties); + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + NotException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => $availableAmount, + 'composedValueValidation' => '$succeededCompositionElements === 0', + 'postPropose' => false, + 'mergedProperty' => null, + 'onlyForDefinedValues' => false, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php new file mode 100644 index 00000000..8921981a --- /dev/null +++ b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php @@ -0,0 +1,90 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); + $propertySchema = $this->inheritPropertyType($propertySchema); + + $onlyForDefinedValues = !($property instanceof BaseProperty) + && (!$property->isRequired() + && $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()); + + $wrappedSchema = $propertySchema->withJson([ + 'type' => $this->key, + 'propertySchema' => $propertySchema, + 'onlyForDefinedValues' => $onlyForDefinedValues, + ]); + + $compositionProperties = $this->getCompositionProperties( + $schemaProcessor, + $schema, + $property, + $wrappedSchema, + false, + ); + + $resolvedCompositions = 0; + foreach ($compositionProperties as $compositionProperty) { + $compositionProperty->onResolve( + function () use (&$resolvedCompositions, $property, $compositionProperties): void { + if (++$resolvedCompositions === count($compositionProperties)) { + $this->transferPropertyType($property, $compositionProperties, false); + } + }, + ); + } + + $availableAmount = count($compositionProperties); + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + OneOfException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => $availableAmount, + 'composedValueValidation' => '$succeededCompositionElements === 1', + 'postPropose' => true, + 'mergedProperty' => null, + 'onlyForDefinedValues' => $onlyForDefinedValues, + ], + ), + 100, + ); + } +} diff --git a/src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php b/src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php deleted file mode 100644 index 50e3cfdd..00000000 --- a/src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php +++ /dev/null @@ -1,245 +0,0 @@ -getJson()['propertySchema']->getJson(); - - if ( - empty($json[$propertySchema->getJson()['type']]) && - $this->schemaProcessor->getGeneratorConfiguration()->isOutputEnabled() - ) { - // @codeCoverageIgnoreStart - echo "Warning: empty composition for {$property->getName()} may lead to unexpected results\n"; - // @codeCoverageIgnoreEnd - } - - $compositionProperties = $this->getCompositionProperties($property, $propertySchema); - - $resolvedCompositions = 0; - foreach ($compositionProperties as $compositionProperty) { - $compositionProperty->onResolve( - function () use (&$resolvedCompositions, $property, $compositionProperties, $propertySchema): void { - if (++$resolvedCompositions === count($compositionProperties)) { - $this->transferPropertyType($property, $compositionProperties); - - $this->mergedProperty = !$this->rootLevelComposition - && $this instanceof MergedComposedPropertiesInterface - ? $this->schemaProcessor->createMergedProperty( - $this->schema, - $property, - $compositionProperties, - $propertySchema, - ) - : null; - } - }, - ); - } - - $availableAmount = count($compositionProperties); - - $property->addValidator( - new ComposedPropertyValidator( - $this->schemaProcessor->getGeneratorConfiguration(), - $property, - $compositionProperties, - static::class, - [ - 'compositionProperties' => $compositionProperties, - 'schema' => $this->schema, - 'generatorConfiguration' => $this->schemaProcessor->getGeneratorConfiguration(), - 'viewHelper' => new RenderHelper($this->schemaProcessor->getGeneratorConfiguration()), - 'availableAmount' => $availableAmount, - 'composedValueValidation' => $this->getComposedValueValidation($availableAmount), - // if the property is a composed property the resulting value of a validation must be proposed - // to be the final value after the validations (e.g. object instantiations may be performed). - // Otherwise (eg. a NotProcessor) the value must be proposed before the validation - 'postPropose' => $this instanceof ComposedPropertiesInterface, - 'mergedProperty' => &$this->mergedProperty, - 'onlyForDefinedValues' => - $propertySchema->getJson()['onlyForDefinedValues'] - && $this instanceof ComposedPropertiesInterface, - ], - ), - 100, - ); - } - - /** - * Set up composition properties for the given property schema - * - * @return CompositionPropertyDecorator[] - * - * @throws SchemaException - */ - protected function getCompositionProperties(PropertyInterface $property, JsonSchema $propertySchema): array - { - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); - $compositionProperties = []; - $json = $propertySchema->getJson()['propertySchema']->getJson(); - - // clear the base type of the property to keep only the types of the composition. - // This avoids e.g. "array|int[]" for a property which is known to contain always an integer array - $property->addTypeHintDecorator(new ClearTypeHintDecorator()); - - foreach ($json[$propertySchema->getJson()['type']] as $compositionElement) { - $compositionSchema = $propertySchema->getJson()['propertySchema']->withJson($compositionElement); - - $compositionProperty = new CompositionPropertyDecorator( - $property->getName(), - $compositionSchema, - $propertyFactory->create( - $this->schemaProcessor, - $this->schema, - $property->getName(), - $compositionSchema, - $property->isRequired(), - ), - ); - - $compositionProperty->onResolve(function () use ($compositionProperty, $property): void { - $compositionProperty->filterValidators( - static fn(Validator $validator): bool => - !is_a($validator->getValidator(), RequiredPropertyValidator::class) && - !is_a($validator->getValidator(), ComposedPropertyValidator::class) - ); - - // only create a composed type hint if we aren't a AnyOf or an AllOf processor and the - // compositionProperty contains no object. This results in objects being composed each separately for a - // OneOf processor (e.g. string|ObjectA|ObjectB). For a merged composed property the objects are merged - // together, so it results in string|MergedObject - if (!($this instanceof MergedComposedPropertiesInterface && $compositionProperty->getNestedSchema())) { - $property->addTypeHintDecorator(new CompositionTypeHintDecorator($compositionProperty)); - } - }); - - $compositionProperties[] = $compositionProperty; - } - - return $compositionProperties; - } - - /** - * Check if the provided property can inherit a single type from the composition properties. - * - * @param CompositionPropertyDecorator[] $compositionProperties - */ - private function transferPropertyType(PropertyInterface $property, array $compositionProperties): void - { - if ($this instanceof NotProcessor) { - return; - } - - // Skip widening when any branch has a nested schema (object): the merged-property - // mechanism creates a combined class whose name is not among the per-branch type names. - foreach ($compositionProperties as $compositionProperty) { - if ($compositionProperty->getNestedSchema() !== null) { - return; - } - } - - // Flatten all type names from all branches. Use getNames() to handle branches that - // already carry a union PropertyType. - $allNames = array_merge(...array_map( - static fn(CompositionPropertyDecorator $compositionProperty): array => - $compositionProperty->getType() ? $compositionProperty->getType()->getNames() : [], - $compositionProperties, - )); - - // A branch with no type contributes nothing but signals that nullable=true is required. - $hasBranchWithNoType = array_filter( - $compositionProperties, - static fn(CompositionPropertyDecorator $compositionProperty): bool => - $compositionProperty->getType() === null, - ) !== []; - - // An optional branch (property not required in that branch) means the property can be - // absent at runtime, causing the root getter to return null. This is a structural - // nullable — independent of the implicit-null configuration setting. - // - // For oneOf/anyOf: any optional branch makes the property nullable (the branch that - // omits the property can match, leaving the value as null). - // - // For allOf: all branches must hold simultaneously. If at least one branch marks the - // property as required, the property is required overall — an optional branch in allOf - // does not by itself make the property nullable. Only if NO branch requires the property - // (i.e. the property is optional across all allOf branches) is it structurally nullable. - $hasBranchWithRequiredProperty = array_filter( - $compositionProperties, - static fn(CompositionPropertyDecorator $compositionProperty): bool => - $compositionProperty->isRequired(), - ) !== []; - $hasBranchWithOptionalProperty = $this instanceof AllOfProcessor - ? !$hasBranchWithRequiredProperty - : array_filter( - $compositionProperties, - static fn(CompositionPropertyDecorator $compositionProperty): bool => - !$compositionProperty->isRequired(), - ) !== []; - - // Strip 'null' → nullable flag; PropertyType constructor deduplicates the rest. - $hasNull = in_array('null', $allNames, true); - $nonNullNames = array_values(array_filter( - array_unique($allNames), - fn(string $t): bool => $t !== 'null', - )); - - if (!$nonNullNames) { - return; - } - - $nullable = ($hasNull || $hasBranchWithNoType || $hasBranchWithOptionalProperty) ? true : null; - - $property->setType(new PropertyType($nonNullNames, $nullable)); - } - - /** - * @param int $composedElements The amount of elements which are composed together - */ - abstract protected function getComposedValueValidation(int $composedElements): string; -} diff --git a/src/PropertyProcessor/ComposedValue/AllOfProcessor.php b/src/PropertyProcessor/ComposedValue/AllOfProcessor.php deleted file mode 100644 index aea3321f..00000000 --- a/src/PropertyProcessor/ComposedValue/AllOfProcessor.php +++ /dev/null @@ -1,23 +0,0 @@ - 0"; - } -} diff --git a/src/PropertyProcessor/ComposedValue/ComposedPropertiesInterface.php b/src/PropertyProcessor/ComposedValue/ComposedPropertiesInterface.php deleted file mode 100644 index daa590b0..00000000 --- a/src/PropertyProcessor/ComposedValue/ComposedPropertiesInterface.php +++ /dev/null @@ -1,14 +0,0 @@ -getJson()['propertySchema']->getJson(); - - if (!isset($json['then']) && !isset($json['else'])) { - throw new SchemaException( - sprintf( - 'Incomplete conditional composition for property %s in file %s', - $property->getName(), - $property->getJsonSchema()->getFile(), - ) - ); - } - - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); - - $properties = []; - - foreach (['if', 'then', 'else'] as $compositionElement) { - if (!isset($json[$compositionElement])) { - $properties[$compositionElement] = null; - continue; - } - - $compositionSchema = $propertySchema->getJson()['propertySchema']->withJson($json[$compositionElement]); - - $compositionProperty = new CompositionPropertyDecorator( - $property->getName(), - $compositionSchema, - $propertyFactory - ->create( - $this->schemaProcessor, - $this->schema, - $property->getName(), - $compositionSchema, - $property->isRequired(), - ) - ); - - $compositionProperty->onResolve(static function () use ($compositionProperty): void { - $compositionProperty->filterValidators(static fn(Validator $validator): bool => - !is_a($validator->getValidator(), RequiredPropertyValidator::class) && - !is_a($validator->getValidator(), ComposedPropertyValidator::class),); - }); - - $properties[$compositionElement] = $compositionProperty; - } - - $property->addValidator( - new ConditionalPropertyValidator( - $this->schemaProcessor->getGeneratorConfiguration(), - $property, - array_filter($properties), - array_filter([$properties['then'], $properties['else']]), - [ - 'ifProperty' => $properties['if'], - 'thenProperty' => $properties['then'], - 'elseProperty' => $properties['else'], - 'schema' => $this->schema, - 'generatorConfiguration' => $this->schemaProcessor->getGeneratorConfiguration(), - 'viewHelper' => new RenderHelper($this->schemaProcessor->getGeneratorConfiguration()), - 'onlyForDefinedValues' => $propertySchema->getJson()['onlyForDefinedValues'], - ], - ), - 100, - ); - - //parent::generateValidators($property, $propertySchema); - } -} diff --git a/src/PropertyProcessor/ComposedValue/MergedComposedPropertiesInterface.php b/src/PropertyProcessor/ComposedValue/MergedComposedPropertiesInterface.php deleted file mode 100644 index e4669e81..00000000 --- a/src/PropertyProcessor/ComposedValue/MergedComposedPropertiesInterface.php +++ /dev/null @@ -1,14 +0,0 @@ -getJson()['propertySchema']->getJson(); - - // as the not composition only takes one schema nest it one level deeper to use the ComposedValueProcessor - $json['not'] = [$json['not']]; - - // strict type checks for not constraint to avoid issues with null - $property->setRequired(true); - parent::generateValidators( - $property, - $propertySchema->withJson( - array_merge( - $propertySchema->getJson(), - ['propertySchema' => $propertySchema->getJson()['propertySchema']->withJson($json)], - ) - ), - ); - } - - /** - * @inheritdoc - */ - protected function getComposedValueValidation(int $composedElements): string - { - return '$succeededCompositionElements === 0'; - } -} diff --git a/src/PropertyProcessor/ComposedValue/OneOfProcessor.php b/src/PropertyProcessor/ComposedValue/OneOfProcessor.php deleted file mode 100644 index b8602344..00000000 --- a/src/PropertyProcessor/ComposedValue/OneOfProcessor.php +++ /dev/null @@ -1,21 +0,0 @@ -rootLevelComposition; - } - - return new $processor(...$params); - } -} diff --git a/src/PropertyProcessor/Property/AbstractPropertyProcessor.php b/src/PropertyProcessor/Property/AbstractPropertyProcessor.php index bd4aae48..c021bae9 100644 --- a/src/PropertyProcessor/Property/AbstractPropertyProcessor.php +++ b/src/PropertyProcessor/Property/AbstractPropertyProcessor.php @@ -5,14 +5,10 @@ namespace PHPModelGenerator\PropertyProcessor\Property; use PHPModelGenerator\Exception\SchemaException; -use PHPModelGenerator\Model\Property\BaseProperty; use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; -use PHPModelGenerator\PropertyProcessor\ComposedValueProcessorFactory; -use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintTransferDecorator; -use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorInterface; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -39,115 +35,6 @@ protected function generateValidators(PropertyInterface $property, JsonSchema $p if ($property->isRequired() && !str_starts_with($property->getName(), 'item of array ')) { $property->addValidator(new RequiredPropertyValidator($property), 1); } - - $this->addComposedValueValidator($property, $propertySchema); - } - - /** - * @throws SchemaException - */ - protected function addComposedValueValidator(PropertyInterface $property, JsonSchema $propertySchema): void - { - // For non-root object-type properties, composition keywords are processed in full - // inside the nested schema by ObjectProcessor (via processSchema with rootLevelComposition=true). - // Adding a composition validator here would duplicate the validation at the parent level: - // by the time this validator runs, $value is already an instantiated object, so branch - // instanceof checks against branch-specific classes fail, rejecting valid input. - // It would also inject a _Merged_ class name into the type hint, overriding the correct type. - if (!($property instanceof BaseProperty) && ($propertySchema->getJson()['type'] ?? '') === 'object') { - return; - } - - $composedValueKeywords = ['allOf', 'anyOf', 'oneOf', 'not', 'if']; - $propertyFactory = new PropertyFactory(new ComposedValueProcessorFactory($property instanceof BaseProperty)); - - foreach ($composedValueKeywords as $composedValueKeyword) { - if (!isset($propertySchema->getJson()[$composedValueKeyword])) { - continue; - } - - $propertySchema = $this->inheritPropertyType($propertySchema, $composedValueKeyword); - - $composedProperty = $propertyFactory - ->create( - $this->schemaProcessor, - $this->schema, - $property->getName(), - $propertySchema->withJson([ - 'type' => $composedValueKeyword, - 'propertySchema' => $propertySchema, - 'onlyForDefinedValues' => !($this instanceof BaseProcessor) && - (!$property->isRequired() - && $this->schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()), - ]), - $property->isRequired(), - ); - - foreach ($composedProperty->getValidators() as $validator) { - $property->addValidator($validator->getValidator(), $validator->getPriority()); - } - - $property->addTypeHintDecorator(new TypeHintTransferDecorator($composedProperty)); - - if (!$property->getType() && $composedProperty->getType()) { - $property->setType($composedProperty->getType(), $composedProperty->getType(true)); - } - } - } - - /** - * If the type of a property containing a composition is defined outside of the composition make sure each - * composition which doesn't define a type inherits the type - */ - protected function inheritPropertyType(JsonSchema $propertySchema, string $composedValueKeyword): JsonSchema - { - $json = $propertySchema->getJson(); - - if (!isset($json['type'])) { - return $propertySchema; - } - - if ($json['type'] === 'base') { - $json['type'] = 'object'; - } - - switch ($composedValueKeyword) { - case 'not': - if (!isset($json[$composedValueKeyword]['type'])) { - $json[$composedValueKeyword]['type'] = $json['type']; - } - break; - case 'if': - return $this->inheritIfPropertyType($propertySchema->withJson($json)); - default: - foreach ($json[$composedValueKeyword] as &$composedElement) { - if (!isset($composedElement['type'])) { - $composedElement['type'] = $json['type']; - } - } - } - - return $propertySchema->withJson($json); - } - - /** - * Inherit the type of a property into all composed components of a conditional composition - */ - protected function inheritIfPropertyType(JsonSchema $propertySchema): JsonSchema - { - $json = $propertySchema->getJson(); - - foreach (['if', 'then', 'else'] as $composedValueKeyword) { - if (!isset($json[$composedValueKeyword])) { - continue; - } - - if (!isset($json[$composedValueKeyword]['type'])) { - $json[$composedValueKeyword]['type'] = $json['type']; - } - } - - return $propertySchema->withJson($json); } /** diff --git a/src/PropertyProcessor/Property/BaseProcessor.php b/src/PropertyProcessor/Property/BaseProcessor.php index 6bc9d4b1..13ca904c 100644 --- a/src/PropertyProcessor/Property/BaseProcessor.php +++ b/src/PropertyProcessor/Property/BaseProcessor.php @@ -30,8 +30,8 @@ use PHPModelGenerator\Model\Validator\PropertyValidator; use PHPModelGenerator\Model\Validator\PropertyDependencyValidator; use PHPModelGenerator\Model\Validator\SchemaDependencyValidator; -use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor; -use PHPModelGenerator\PropertyProcessor\ComposedValue\ComposedPropertiesInterface; +use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Composition\ComposedPropertiesValidatorFactoryInterface; use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; @@ -369,7 +369,13 @@ protected function transferComposedPropertiesToSchema(PropertyInterface $propert : $validator, ); - if (!is_a($validator->getCompositionProcessor(), ComposedPropertiesInterface::class, true)) { + if ( + !is_a( + $validator->getCompositionProcessor(), + ComposedPropertiesValidatorFactoryInterface::class, + true, + ) + ) { continue; } @@ -458,7 +464,7 @@ private function cloneTransferredProperty( ->filterValidators(static fn(Validator $validator): bool => is_a($validator->getValidator(), PropertyTemplateValidator::class)); - if (!is_a($compositionProcessor, AllOfProcessor::class, true)) { + if (!is_a($compositionProcessor, AllOfValidatorFactory::class, true)) { $transferredProperty->setRequired(false); if ($transferredProperty->getType()) { diff --git a/src/SchemaProcessor/PostProcessor/Internal/CompositionRequiredPromotionPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/CompositionRequiredPromotionPostProcessor.php index 519642b1..a5622c1b 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/CompositionRequiredPromotionPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/CompositionRequiredPromotionPostProcessor.php @@ -11,7 +11,7 @@ use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; use PHPModelGenerator\Model\Validator\ComposedPropertyValidator; use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator; -use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor; +use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; /** @@ -100,7 +100,7 @@ private function collectFromComposed(ComposedPropertyValidator $validator): arra $branches, ); - if (is_a($validator->getCompositionProcessor(), AllOfProcessor::class, true)) { + if (is_a($validator->getCompositionProcessor(), AllOfValidatorFactory::class, true)) { return array_values(array_unique(array_merge(...$requiredPerBranch))); } diff --git a/src/SchemaProcessor/SchemaProcessor.php b/src/SchemaProcessor/SchemaProcessor.php index b58c65eb..f1d5cdf4 100644 --- a/src/SchemaProcessor/SchemaProcessor.php +++ b/src/SchemaProcessor/SchemaProcessor.php @@ -19,8 +19,8 @@ use PHPModelGenerator\Model\Validator\ComposedPropertyValidator; use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator; use PHPModelGenerator\Model\Validator\PropertyTemplateValidator; -use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor; -use PHPModelGenerator\PropertyProcessor\ComposedValue\ComposedPropertiesInterface; +use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Composition\ComposedPropertiesValidatorFactoryInterface; use PHPModelGenerator\PropertyProcessor\Decorator\Property\ObjectInstantiationDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator; @@ -484,7 +484,13 @@ public function transferComposedPropertiesToSchema(PropertyInterface $property, : $validator, ); - if (!is_a($validator->getCompositionProcessor(), ComposedPropertiesInterface::class, true)) { + if ( + !is_a( + $validator->getCompositionProcessor(), + ComposedPropertiesValidatorFactoryInterface::class, + true, + ) + ) { continue; } @@ -576,7 +582,7 @@ private function cloneTransferredProperty( ->filterValidators(static fn(Validator $v): bool => is_a($v->getValidator(), PropertyTemplateValidator::class)); - if (!is_a($compositionProcessor, AllOfProcessor::class, true)) { + if (!is_a($compositionProcessor, AllOfValidatorFactory::class, true)) { $transferredProperty->setRequired(false); if ($transferredProperty->getType()) { From ab9ae62218b9a0fa4b05f4f74fe03923653de718 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 30 Mar 2026 10:39:43 +0200 Subject: [PATCH 8/9] Implement Phase 8: eliminate all legacy processor classes; introduce ConstModifier, IntToFloatModifier, NullModifier - Delete all PropertyProcessor/Property/* classes (AbstractPropertyProcessor, AbstractValueProcessor, AbstractTypedValueProcessor, AbstractNumericProcessor, StringProcessor, IntegerProcessor, NumberProcessor, BooleanProcessor, ArrayProcessor, ObjectProcessor, NullProcessor, AnyProcessor, ConstProcessor, ReferenceProcessor, BasereferenceProcessor, BaseProcessor) - Delete PropertyProcessorFactory and ProcessorFactoryInterface - Remove ProcessorFactoryInterface constructor parameter from PropertyFactory - Inline $ref / baseReference routing as private methods on PropertyFactory - Refactor PropertyFactory::create into focused dispatch + four extracted methods (createObjectProperty, createBaseProperty, createTypedProperty, buildProperty) with a single unified applyModifiers helper replacing three separate methods - Add ConstModifier (registered on 'any' type): sets PropertyType from const value type and adds InvalidConstException validator; immutability is fully respected - Add IntToFloatModifier (registered on 'number' type): adds IntToFloatCastDecorator - Add NullModifier (registered on 'null' type): clears PropertyType and adds TypeHintDecorator - Register object type in Draft_07 with typeCheck=false (PropertyFactory handles object type-check via wireObjectProperty, not via Draft modifier dispatch) - Remove stale PropertyProcessorFactory imports from validator/schema files - Add ConstPropertyTest::testConstPropertyHasNoSetterWhenImmutable - Add docs/source/generator/custom/customDraft.rst: custom draft / modifier guide - Update setDraft documentation in gettingStarted.rst with seealso link - Update CLAUDE.md architecture section to document the final Draft modifier system --- .../implementation-plan.md | 156 ++++- CLAUDE.md | 20 +- docs/source/generator/custom/customDraft.rst | 126 ++++ docs/source/generator/postProcessor.rst | 3 +- docs/source/gettingStarted.rst | 4 + docs/source/toc-generator.rst | 1 + src/Draft/Draft_07.php | 14 +- .../Modifier/ConstModifier.php} | 42 +- src/Draft/Modifier/IntToFloatModifier.php | 23 + src/Draft/Modifier/NullModifier.php | 24 + .../SchemaDefinition/SchemaDefinition.php | 3 +- .../AdditionalPropertiesValidator.php | 3 +- src/Model/Validator/ArrayItemValidator.php | 3 +- src/Model/Validator/ArrayTupleValidator.php | 3 +- .../Arrays/ContainsValidatorFactory.php | 3 +- .../AbstractCompositionValidatorFactory.php | 3 +- .../Composition/IfValidatorFactory.php | 3 +- .../Object/PropertiesValidatorFactory.php | 3 +- .../Validator/PatternPropertiesValidator.php | 3 +- .../Validator/PropertyNamesValidator.php | 3 +- .../ProcessorFactoryInterface.php | 23 - .../Property/AbstractNumericProcessor.php | 14 - .../Property/AbstractPropertyProcessor.php | 48 -- .../Property/AbstractTypedValueProcessor.php | 105 ---- .../Property/AbstractValueProcessor.php | 60 -- .../Property/AnyProcessor.php | 27 - .../Property/ArrayProcessor.php | 15 - .../Property/BaseProcessor.php | 541 ------------------ .../Property/BasereferenceProcessor.php | 48 -- .../Property/BooleanProcessor.php | 15 - .../Property/IntegerProcessor.php | 15 - .../Property/NullProcessor.php | 29 - .../Property/NumberProcessor.php | 27 - .../Property/ObjectProcessor.php | 81 --- .../Property/ReferenceProcessor.php | 81 --- .../Property/StringProcessor.php | 15 - src/PropertyProcessor/PropertyFactory.php | 483 ++++++++++------ .../PropertyProcessorFactory.php | 40 -- .../PropertyProcessorInterface.php | 24 - src/SchemaProcessor/SchemaProcessor.php | 3 +- tests/Objects/ConstPropertyTest.php | 20 +- .../PropertyProcessorFactoryTest.php | 91 --- 42 files changed, 684 insertions(+), 1564 deletions(-) create mode 100644 docs/source/generator/custom/customDraft.rst rename src/{PropertyProcessor/Property/ConstProcessor.php => Draft/Modifier/ConstModifier.php} (57%) create mode 100644 src/Draft/Modifier/IntToFloatModifier.php create mode 100644 src/Draft/Modifier/NullModifier.php delete mode 100644 src/PropertyProcessor/ProcessorFactoryInterface.php delete mode 100644 src/PropertyProcessor/Property/AbstractNumericProcessor.php delete mode 100644 src/PropertyProcessor/Property/AbstractPropertyProcessor.php delete mode 100644 src/PropertyProcessor/Property/AbstractTypedValueProcessor.php delete mode 100644 src/PropertyProcessor/Property/AbstractValueProcessor.php delete mode 100644 src/PropertyProcessor/Property/AnyProcessor.php delete mode 100644 src/PropertyProcessor/Property/ArrayProcessor.php delete mode 100644 src/PropertyProcessor/Property/BaseProcessor.php delete mode 100644 src/PropertyProcessor/Property/BasereferenceProcessor.php delete mode 100644 src/PropertyProcessor/Property/BooleanProcessor.php delete mode 100644 src/PropertyProcessor/Property/IntegerProcessor.php delete mode 100644 src/PropertyProcessor/Property/NullProcessor.php delete mode 100644 src/PropertyProcessor/Property/NumberProcessor.php delete mode 100644 src/PropertyProcessor/Property/ObjectProcessor.php delete mode 100644 src/PropertyProcessor/Property/ReferenceProcessor.php delete mode 100644 src/PropertyProcessor/Property/StringProcessor.php delete mode 100644 src/PropertyProcessor/PropertyProcessorFactory.php delete mode 100644 src/PropertyProcessor/PropertyProcessorInterface.php delete mode 100644 tests/PropertyProcessor/PropertyProcessorFactoryTest.php diff --git a/.claude/topics/reworkstructure-analysis/implementation-plan.md b/.claude/topics/reworkstructure-analysis/implementation-plan.md index ec6a3122..a06f636b 100644 --- a/.claude/topics/reworkstructure-analysis/implementation-plan.md +++ b/.claude/topics/reworkstructure-analysis/implementation-plan.md @@ -775,11 +775,14 @@ permanent special-case routes inside `PropertyFactory` — they represent keywor --- -## Phase 8 — Delete empty processor classes and legacy bridge +## Phase 8 — Delete empty processor classes and legacy bridge **[DONE]** **Goal**: Remove all now-empty or deprecated processor classes, the legacy bridge in -`PropertyFactory`, `PropertyProcessorFactory`, `ComposedValueProcessorFactory`, and +`PropertyFactory`, `PropertyProcessorFactory`, and `AbstractPropertyProcessor`/`AbstractValueProcessor`/`AbstractTypedValueProcessor`. +Introduce `ConstModifier` and `NumberModifier`. Inline `$ref`/`baseReference` logic as +private methods on `PropertyFactory`. Remove the `ProcessorFactoryInterface` constructor +parameter from `PropertyFactory` entirely. ### Remaining bridge-period debt to clean up @@ -791,8 +794,113 @@ At the time Phase 8 runs, the following bridge artifact must be removed: All PMC-mutation workarounds were already resolved in Phase 2. -### Classes to delete +### 8.1 — `ConstModifier` on the `'any'` type +New file `src/Draft/Modifier/ConstModifier.php` — registered on the `'any'` type in +`Draft_07` via `addModifier`. Does not use the `AbstractValidatorFactory`/`setKey` mechanism +because it needs to set both a `PropertyType` AND add a `PropertyValidator` on the same +property (two things, not just a validator keyed to one keyword). Implements +`ModifierInterface` directly. + +`ConstModifier::modify`: +- Guard: return immediately if `!isset($json['const'])`. +- Set `PropertyType` using `TypeConverter::gettypeToInternal(gettype($json['const']))`. +- Build the validator check expression (same logic as `ConstProcessor::process`): + - `$property->isRequired()` → `'$value !== ' . var_export($json['const'], true)` + - implicit null allowed → `'!in_array($value, [const, null], true)'` + - otherwise → `"array_key_exists('name', \$modelData) && \$value !== const"` +- Add `new PropertyValidator($property, $check, InvalidConstException::class, [$json['const']])`. + +The `$json['type'] = 'const'` routing signal in `PropertyFactory::create` is removed. +`ConstProcessor` is deleted. + +### 8.2 — `NumberModifier` on the `'number'` type + +New file `src/Draft/Modifier/NumberModifier.php` — registered on the `'number'` type in +`Draft_07` via `addModifier`. Adds `IntToFloatCastDecorator` unconditionally (all `number` +properties need the int→float cast). Implements `ModifierInterface` directly. + +`NumberProcessor::process` currently calls `parent::process(...)->addDecorator(new IntToFloatCastDecorator())`. +This moves into `NumberModifier` so `NumberProcessor` can be deleted. + +### 8.3 — Inline `$ref` / `baseReference` as private methods on `PropertyFactory` + +`$ref` is categorically different from keyword validators: `ReferenceProcessor::process` +**replaces** the property entirely (returns a resolved `PropertyProxy` from the definition +dictionary). The `ModifierInterface::modify` contract returns `void` and cannot replace a +property — any attempt to "copy fields" would lose the `PropertyProxy`'s deferred-resolution +callbacks. Therefore `$ref` remains a routing signal handled before Draft modifiers run. + +New private methods on `PropertyFactory`: +- `processReference(SchemaProcessor, Schema, string $propertyName, JsonSchema, bool $required): PropertyInterface` + — inlines `ReferenceProcessor::process` logic +- `processBaseReference(SchemaProcessor, Schema, string $propertyName, JsonSchema, bool $required): PropertyInterface` + — inlines `BasereferenceProcessor::process` logic (calls `processReference`, validates nested + schema, copies properties to parent schema) + +The `$json['$ref'] → type = 'reference'/'baseReference'` detection in `PropertyFactory::create` +remains but routes to these private methods instead of via `processorFactory`. + +`ReferenceProcessor` and `BasereferenceProcessor` are deleted. + +### 8.4 — Inline `base` path; construct `Property`/`BaseProperty` directly in `PropertyFactory` + +The remaining legacy bridge in `PropertyFactory::create` calls `$this->processorFactory->getProcessor(type)->process()` for: +- `base` — `BaseProcessor::process` does `setUpDefinitionDictionary` + constructs `BaseProperty` +- scalar/array/any types — `AbstractTypedValueProcessor::process` constructs `Property`, + sets required/readOnly, calls `generateValidators` (adds `RequiredPropertyValidator` + + `TypeCheckValidator` with dedup guard) + +After Phase 8 these are inlined directly: + +**`base` path**: +```php +$schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema); +$property = new BaseProperty($propertyName, new PropertyType('object'), $propertySchema); +// applyDraftModifiers and transferComposedPropertiesToSchema follow (already in place) +``` + +**Scalar/array/any paths** (`string`, `integer`, `number`, `boolean`, `null`, `array`, `any`): +```php +$property = (new Property($propertyName, null, $propertySchema, $json['description'] ?? '')) + ->setRequired($required) + ->setReadOnly(...); +if ($required && !str_starts_with($propertyName, 'item of array ')) { + $property->addValidator(new RequiredPropertyValidator($property), 1); +} +// applyDraftModifiers follows (TypeCheckValidator, keyword validators all come from Draft) +``` + +`NullProcessor` added `setType(null)` and `TypeHintDecorator(['null'])` — these must be added +as a `NullModifier` on the `'null'` type in `Draft_07`. The TypeCheckModifier already adds the +`TypeCheckValidator('null', ...)` — but `NullProcessor` called `setType(null)` to clear the +type hint. A `NullModifier` runs after `TypeCheckModifier` and calls `$property->setType(null)` +and `$property->addTypeHintDecorator(new TypeHintDecorator(['null']))`. This preserves existing +behaviour: null-typed properties have no PHP type hint (rendered as `mixed`/no type). + +### 8.5 — Remove `ProcessorFactoryInterface` from `PropertyFactory` + +`PropertyFactory::__construct` loses the `ProcessorFactoryInterface $processorFactory` parameter. +All `new PropertyFactory(new PropertyProcessorFactory())` call sites across the codebase become +`new PropertyFactory()`. + +Call sites: +- `src/SchemaProcessor/SchemaProcessor.php` (1 site) +- `src/Model/SchemaDefinition/SchemaDefinition.php` (1 site) +- `src/Model/Validator/AdditionalPropertiesValidator.php` (1 site) +- `src/Model/Validator/ArrayItemValidator.php` (1 site) +- `src/Model/Validator/ArrayTupleValidator.php` (1 site) +- `src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php` (1 site) +- `src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php` (1 site) +- `src/Model/Validator/Factory/Composition/IfValidatorFactory.php` (1 site) +- `src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php` (1 site) +- `src/Model/Validator/PatternPropertiesValidator.php` (1 site) +- `src/Model/Validator/PropertyNamesValidator.php` (1 site) +- `src/PropertyProcessor/Property/BaseProcessor.php` (1 site, deleted in 8.6) + +### 8.6 — Delete all processor classes and infrastructure + +Files to delete: - `src/PropertyProcessor/Property/AbstractPropertyProcessor.php` - `src/PropertyProcessor/Property/AbstractValueProcessor.php` - `src/PropertyProcessor/Property/AbstractTypedValueProcessor.php` @@ -805,35 +913,41 @@ All PMC-mutation workarounds were already resolved in Phase 2. - `src/PropertyProcessor/Property/ArrayProcessor.php` - `src/PropertyProcessor/Property/ObjectProcessor.php` - `src/PropertyProcessor/Property/AnyProcessor.php` -- `src/PropertyProcessor/Property/MultiTypeProcessor.php` (deleted in Phase 5) -- `src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php` -- `src/PropertyProcessor/ComposedValue/AllOfProcessor.php` -- `src/PropertyProcessor/ComposedValue/AnyOfProcessor.php` -- `src/PropertyProcessor/ComposedValue/OneOfProcessor.php` -- `src/PropertyProcessor/ComposedValue/NotProcessor.php` -- `src/PropertyProcessor/ComposedValue/IfProcessor.php` -- `src/PropertyProcessor/ComposedValueProcessorFactory.php` +- `src/PropertyProcessor/Property/ConstProcessor.php` (replaced by ConstModifier in 8.1) +- `src/PropertyProcessor/Property/ReferenceProcessor.php` (inlined in 8.3) +- `src/PropertyProcessor/Property/BasereferenceProcessor.php` (inlined in 8.3) +- `src/PropertyProcessor/Property/BaseProcessor.php` (inlined in 8.4) - `src/PropertyProcessor/PropertyProcessorFactory.php` - `src/PropertyProcessor/ProcessorFactoryInterface.php` - `src/PropertyProcessor/PropertyProcessorInterface.php` -### Tests to delete/replace +The `src/PropertyProcessor/Property/` directory becomes empty and is removed. -- `tests/PropertyProcessor/PropertyProcessorFactoryTest.php` — tests the deleted factory. - Replace with `tests/Draft/DraftRegistryTest.php` testing that `Draft07` returns the correct - modifier list for each type. +### 8.7 — Delete `BaseProcessor::transferComposedPropertiesToSchema` -### Remaining processors (kept permanently) +`BaseProcessor` contains a `transferComposedPropertiesToSchema` method that duplicates the +identical method on `SchemaProcessor`. `PropertyFactory`'s `base` path already calls +`$schemaProcessor->transferComposedPropertiesToSchema`. The `BaseProcessor` copy is dead code +and is deleted along with the class. -- `src/PropertyProcessor/Property/ConstProcessor.php` — becomes a standalone callable, no - longer part of the processor hierarchy; or folds into `PropertyFactory` directly -- `src/PropertyProcessor/Property/ReferenceProcessor.php` — same -- `src/PropertyProcessor/Property/BasereferenceProcessor.php` — same +### Tests for Phase 8 + +- Delete `tests/PropertyProcessor/PropertyProcessorFactoryTest.php`. +- Add `tests/Draft/DraftRegistryTest.php` — verifies `Draft_07::getDefinition()->build()`: + - Returns the correct modifier list (including new modifiers) for each type + - `'null'` type has `NullModifier` in its list + - `'number'` type has `NumberModifier` in its list + - `'any'` type has `ConstModifier` in its list + - All types have `TypeCheckModifier` (except `'object'` and `'any'`) +- Add `tests/Draft/Modifier/ConstModifierTest.php` — unit tests for `ConstModifier` +- Add `tests/Draft/Modifier/NumberModifierTest.php` — unit tests for `NumberModifier` +- Add `tests/Draft/Modifier/NullModifierTest.php` — unit tests for `NullModifier` +- Full integration suite must stay green. ### Docs for Phase 8 - Remove all references to `PropertyProcessorFactory` from docs. -- Update architecture overview in docs and `CLAUDE.md`. +- Update architecture overview in `CLAUDE.md`. - Document the final `DraftInterface` / modifier system for users. --- diff --git a/CLAUDE.md b/CLAUDE.md index 20567ad1..0c76f16b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ Tests write generated PHP classes to `sys_get_temp_dir()/PHPModelGeneratorTest/M ### Running the full test suite -When running the full test suite (all 2246 tests), always save output to a file so the complete +When running the full test suite, always save output to a file so the complete output is available for analysis without re-running. Use `--display-warnings` to capture warning details and `--no-coverage` to skip slow coverage collection: @@ -92,15 +92,25 @@ This library generates PHP model classes from JSON Schema files. The process is ### Schema Processing `SchemaProcessor` (`src/SchemaProcessor/SchemaProcessor.php`) orchestrates property parsing: -- Uses `PropertyProcessorFactory` to instantiate the correct processor by JSON type (String, Integer, Number, Boolean, Array, Object, Null, Const, Any, Reference) -- Convention: processor class name is `PHPModelGenerator\PropertyProcessor\Property\{Type}Processor` +- Uses `PropertyFactory` (`src/PropertyProcessor/PropertyFactory.php`) to create and configure each property +- `PropertyFactory` resolves `$ref` references, delegates `object` types to `processSchema`, and for all other types constructs a `Property` directly and applies Draft modifiers - `ComposedValueProcessorFactory` handles `allOf`, `anyOf`, `oneOf`, `if/then/else`, `not` - `SchemaDefinitionDictionary` tracks `$ref` definitions to avoid duplicate processing +### Draft System (`src/Draft/`) + +The Draft system defines per-type modifier and validator registrations: +- **`DraftInterface`** / **`DraftBuilder`** / **`Draft`** — Draft definition, builder, and built (immutable) registry +- **`Draft_07.php`** — The JSON Schema Draft 7 definition; registers all types, modifiers, and validator factories +- **`Element/Type`** — One entry per JSON Schema type; holds an ordered list of `ModifierInterface` instances +- **`Modifier/`** — `TypeCheckModifier`, `ConstModifier`, `NumberModifier`, `NullModifier`, `ObjectType/ObjectModifier`, `DefaultValueModifier`, `DefaultArrayToEmptyArrayModifier`; each implements `ModifierInterface::modify()` +- **`Model/Validator/Factory/`** — `AbstractValidatorFactory` subclasses keyed to schema keywords (e.g. `MinLengthPropertyValidatorFactory` for `minLength`); run as modifiers when a matching key exists in the schema + +`PropertyFactory::applyDraftModifiers` resolves `getCoveredTypes($type)` (which always includes `'any'`) and runs every modifier for each covered type in order. + ### Property Processors (`src/PropertyProcessor/`) -- `Property/` — One processor per JSON Schema type -- `ComposedValue/` — Processors for composition keywords +- `ComposedValue/` — Processors for composition keywords (`allOf`, `anyOf`, `oneOf`, `if/then/else`, `not`) - `Filter/` — Custom filter processing - `Decorator/` — Property decorators (ObjectInstantiation, PropertyTransfer, IntToFloatCast, etc.) diff --git a/docs/source/generator/custom/customDraft.rst b/docs/source/generator/custom/customDraft.rst new file mode 100644 index 00000000..1731bd96 --- /dev/null +++ b/docs/source/generator/custom/customDraft.rst @@ -0,0 +1,126 @@ +Custom Drafts +============= + +The *Draft* system defines which JSON Schema keywords are recognised during model generation and +how each keyword is translated into PHP code. Every ``GeneratorConfiguration`` has a +*draft* — by default ``AutoDetectionDraft``, which inspects the ``$schema`` keyword in each +schema file and picks the appropriate draft automatically. + +You can replace the default draft with a concrete ``DraftInterface`` implementation to pin all +schemas to a single set of rules, or supply a ``DraftFactoryInterface`` implementation to choose +the draft dynamically per schema file. + +Concepts +-------- + +Type + A named JSON Schema type (``"object"``, ``"string"``, ``"integer"``, ``"number"``, + ``"boolean"``, ``"array"``, ``"null"``, or the virtual ``"any"`` type that applies to every + property regardless of its declared type). + + Each type entry holds an ordered list of *modifiers*. + +Modifier (``ModifierInterface``) + A unit of work that reads the raw JSON Schema for a property and modifies the in-memory + ``PropertyInterface`` object — adding validators, decorators, type hints, or any other + enrichment. Modifiers are executed in registration order, and the ``"any"`` modifiers run + for every property. + +Validator factory (``AbstractValidatorFactory``) + A special modifier that is keyed to a single JSON Schema keyword (e.g. ``"minLength"``). + It checks whether that keyword is present in the schema and, if so, adds the corresponding + validator to the property. Validator factories are registered via ``Type::addValidator()``. + +Implementing a custom draft +--------------------------- + +The simplest approach is to extend an existing draft (e.g. ``Draft_07``) via the builder API and +override the parts you need. + +**Example: add a custom keyword modifier to Draft 7** + +.. code-block:: php + + use PHPModelGenerator\Draft\Draft_07; + use PHPModelGenerator\Draft\DraftBuilder; + use PHPModelGenerator\Draft\DraftInterface; + use PHPModelGenerator\Draft\Modifier\ModifierInterface; + use PHPModelGenerator\Model\Property\PropertyInterface; + use PHPModelGenerator\Model\Schema; + use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; + use PHPModelGenerator\SchemaProcessor\SchemaProcessor; + + class DeprecatedModifier implements ModifierInterface + { + public function modify( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + if (!($propertySchema->getJson()['deprecated'] ?? false)) { + return; + } + + // Add a PHPDoc annotation or any other enrichment here. + } + } + + class MyDraft implements DraftInterface + { + public function getDefinition(): DraftBuilder + { + // Obtain the standard Draft 7 builder and append a modifier to the 'any' type. + $builder = (new Draft_07())->getDefinition(); + $builder->getType('any')->addModifier(new DeprecatedModifier()); + + return $builder; + } + } + +Register the custom draft in your generator configuration: + +.. code-block:: php + + use PHPModelGenerator\Model\GeneratorConfiguration; + + $configuration = (new GeneratorConfiguration()) + ->setDraft(new MyDraft()); + +Implementing a draft factory +----------------------------- + +If you need to select the draft dynamically per schema file, implement +``DraftFactoryInterface``: + +.. code-block:: php + + use PHPModelGenerator\Draft\AutoDetectionDraft; + use PHPModelGenerator\Draft\DraftFactoryInterface; + use PHPModelGenerator\Draft\DraftInterface; + use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; + + class MyDraftFactory implements DraftFactoryInterface + { + private AutoDetectionDraft $autoDetect; + private MyDraft $myDraft; + + public function __construct() + { + $this->autoDetect = new AutoDetectionDraft(); + $this->myDraft = new MyDraft(); + } + + public function getDraftForSchema(JsonSchema $schema): DraftInterface + { + // Use the custom draft only for schemas that opt in via a custom flag. + if ($schema->getJson()['x-use-my-draft'] ?? false) { + return $this->myDraft; + } + + return $this->autoDetect->getDraftForSchema($schema); + } + } + + $configuration = (new GeneratorConfiguration()) + ->setDraft(new MyDraftFactory()); diff --git a/docs/source/generator/postProcessor.rst b/docs/source/generator/postProcessor.rst index 2ed64b35..6a045a05 100644 --- a/docs/source/generator/postProcessor.rst +++ b/docs/source/generator/postProcessor.rst @@ -24,7 +24,8 @@ All added post processors will be executed after a schema was processed and befo builtin/patternPropertiesAccessorPostProcessor .. toctree:: - :caption: Custom Post Processors + :caption: Custom Extensions :maxdepth: 1 custom/customPostProcessor + custom/customDraft diff --git a/docs/source/gettingStarted.rst b/docs/source/gettingStarted.rst index 1bd33e41..7cbee0f8 100644 --- a/docs/source/gettingStarted.rst +++ b/docs/source/gettingStarted.rst @@ -333,6 +333,10 @@ Draft class Description ``Draft_07`` JSON Schema Draft 7 (default) ============= ================================ +.. seealso:: + + :doc:`generator/custom/customDraft` — how to implement a custom draft or modifier. + Custom filter ^^^^^^^^^^^^^ diff --git a/docs/source/toc-generator.rst b/docs/source/toc-generator.rst index 7ce90705..b7d34c6c 100644 --- a/docs/source/toc-generator.rst +++ b/docs/source/toc-generator.rst @@ -3,3 +3,4 @@ :maxdepth: 2 generator/postProcessor + generator/custom/customDraft diff --git a/src/Draft/Draft_07.php b/src/Draft/Draft_07.php index 1d6b3e30..d9de3114 100644 --- a/src/Draft/Draft_07.php +++ b/src/Draft/Draft_07.php @@ -11,7 +11,10 @@ use PHPModelGenerator\Model\Validator\Factory\Composition\IfValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\Composition\NotValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\Composition\OneOfValidatorFactory; +use PHPModelGenerator\Draft\Modifier\ConstModifier; use PHPModelGenerator\Draft\Modifier\DefaultValueModifier; +use PHPModelGenerator\Draft\Modifier\IntToFloatModifier; +use PHPModelGenerator\Draft\Modifier\NullModifier; use PHPModelGenerator\Draft\Modifier\ObjectType\ObjectModifier; use PHPModelGenerator\Model\Validator\Factory\Any\EnumValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\Any\FilterValidatorFactory; @@ -41,7 +44,7 @@ class Draft_07 implements DraftInterface public function getDefinition(): DraftBuilder { return (new DraftBuilder()) - ->addType((new Type('object')) + ->addType((new Type('object', false)) ->addValidator('properties', new PropertiesValidatorFactory()) ->addValidator('propertyNames', new PropertyNamesValidatorFactory()) ->addValidator('patternProperties', new PatternPropertiesValidatorFactory()) @@ -72,9 +75,11 @@ public function getDefinition(): DraftBuilder ->addValidator('maximum', new MaximumValidatorFactory('is_float')) ->addValidator('exclusiveMinimum', new ExclusiveMinimumValidatorFactory('is_float')) ->addValidator('exclusiveMaximum', new ExclusiveMaximumValidatorFactory('is_float')) - ->addValidator('multipleOf', new MultipleOfPropertyValidatorFactory('is_float', false))) + ->addValidator('multipleOf', new MultipleOfPropertyValidatorFactory('is_float', false)) + ->addModifier(new IntToFloatModifier())) ->addType(new Type('boolean')) - ->addType(new Type('null')) + ->addType((new Type('null')) + ->addModifier(new NullModifier())) ->addType((new Type('any', false)) ->addValidator('enum', new EnumValidatorFactory()) ->addValidator('filter', new FilterValidatorFactory()) @@ -83,6 +88,7 @@ public function getDefinition(): DraftBuilder ->addValidator('oneOf', new OneOfValidatorFactory()) ->addValidator('not', new NotValidatorFactory()) ->addValidator('if', new IfValidatorFactory()) - ->addModifier(new DefaultValueModifier())); + ->addModifier(new DefaultValueModifier()) + ->addModifier(new ConstModifier())); } } diff --git a/src/PropertyProcessor/Property/ConstProcessor.php b/src/Draft/Modifier/ConstModifier.php similarity index 57% rename from src/PropertyProcessor/Property/ConstProcessor.php rename to src/Draft/Modifier/ConstModifier.php index d71b60c6..0c145d65 100644 --- a/src/PropertyProcessor/Property/ConstProcessor.php +++ b/src/Draft/Modifier/ConstModifier.php @@ -2,47 +2,43 @@ declare(strict_types=1); -namespace PHPModelGenerator\PropertyProcessor\Property; +namespace PHPModelGenerator\Draft\Modifier; use PHPModelGenerator\Exception\Generic\InvalidConstException; -use PHPModelGenerator\Model\Property\Property; use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Property\PropertyType; +use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator\PropertyValidator; +use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; use PHPModelGenerator\Utils\TypeConverter; -/** - * Class ConstProcessor - * - * @package PHPModelGenerator\PropertyProcessor\Property - */ -class ConstProcessor extends AbstractPropertyProcessor +class ConstModifier implements ModifierInterface { - /** - * @inheritdoc - */ - public function process(string $propertyName, JsonSchema $propertySchema): PropertyInterface - { + public function modify( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { $json = $propertySchema->getJson(); - $property = new Property( - $propertyName, + if (!array_key_exists('const', $json)) { + return; + } + + $property->setType( new PropertyType(TypeConverter::gettypeToInternal(gettype($json['const']))), - $propertySchema, - $json['description'] ?? '', ); - $property->setRequired($this->required); - $check = match (true) { $property->isRequired() => '$value !== ' . var_export($json['const'], true), - $this->isImplicitNullAllowed($property) + $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() && !$property->isRequired() => '!in_array($value, ' . RenderHelper::varExportArray([$json['const'], null]) . ', true)', default - => "array_key_exists('{$property->getName()}', \$modelData) && \$value !== " + => "array_key_exists('" . addslashes($property->getName()) . "', \$modelData) && \$value !== " . var_export($json['const'], true), }; @@ -52,9 +48,5 @@ public function process(string $propertyName, JsonSchema $propertySchema): Prope InvalidConstException::class, [$json['const']], )); - - $this->generateValidators($property, $propertySchema); - - return $property; } } diff --git a/src/Draft/Modifier/IntToFloatModifier.php b/src/Draft/Modifier/IntToFloatModifier.php new file mode 100644 index 00000000..6b90eee1 --- /dev/null +++ b/src/Draft/Modifier/IntToFloatModifier.php @@ -0,0 +1,23 @@ +addDecorator(new IntToFloatCastDecorator()); + } +} diff --git a/src/Draft/Modifier/NullModifier.php b/src/Draft/Modifier/NullModifier.php new file mode 100644 index 00000000..961b2ac9 --- /dev/null +++ b/src/Draft/Modifier/NullModifier.php @@ -0,0 +1,24 @@ +setType(null); + $property->addTypeHintDecorator(new TypeHintDecorator(['null'])); + } +} diff --git a/src/Model/SchemaDefinition/SchemaDefinition.php b/src/Model/SchemaDefinition/SchemaDefinition.php index a1217dd2..c8676587 100644 --- a/src/Model/SchemaDefinition/SchemaDefinition.php +++ b/src/Model/SchemaDefinition/SchemaDefinition.php @@ -10,7 +10,6 @@ use PHPModelGenerator\Model\Property\PropertyProxy; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; /** @@ -75,7 +74,7 @@ public function resolveReference( $this->resolvedPaths->offsetSet($key, null); try { - $property = (new PropertyFactory(new PropertyProcessorFactory())) + $property = (new PropertyFactory()) ->create( $this->schemaProcessor, $this->schema, diff --git a/src/Model/Validator/AdditionalPropertiesValidator.php b/src/Model/Validator/AdditionalPropertiesValidator.php index 1a63f2c0..66e1816f 100644 --- a/src/Model/Validator/AdditionalPropertiesValidator.php +++ b/src/Model/Validator/AdditionalPropertiesValidator.php @@ -11,7 +11,6 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -43,7 +42,7 @@ public function __construct( JsonSchema $propertiesStructure, ?string $propertyName = null, ) { - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); + $propertyFactory = new PropertyFactory(); $this->validationProperty = $propertyFactory->create( $schemaProcessor, diff --git a/src/Model/Validator/ArrayItemValidator.php b/src/Model/Validator/ArrayItemValidator.php index 117c3de9..ddfa5971 100644 --- a/src/Model/Validator/ArrayItemValidator.php +++ b/src/Model/Validator/ArrayItemValidator.php @@ -11,7 +11,6 @@ use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ArrayTypeHintDecorator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -40,7 +39,7 @@ public function __construct( $this->variableSuffix = '_' . md5($nestedPropertyName); // an item of the array behaves like a nested property to add item-level validation - $this->nestedProperty = (new PropertyFactory(new PropertyProcessorFactory())) + $this->nestedProperty = (new PropertyFactory()) ->create( $schemaProcessor, $schema, diff --git a/src/Model/Validator/ArrayTupleValidator.php b/src/Model/Validator/ArrayTupleValidator.php index 09ef1b0f..94a1ee22 100644 --- a/src/Model/Validator/ArrayTupleValidator.php +++ b/src/Model/Validator/ArrayTupleValidator.php @@ -11,7 +11,6 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -36,7 +35,7 @@ public function __construct( JsonSchema $propertiesStructure, string $propertyName, ) { - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); + $propertyFactory = new PropertyFactory(); $this->tupleProperties = []; diff --git a/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php index ca0bb529..5fb0eefd 100644 --- a/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php +++ b/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php @@ -12,7 +12,6 @@ use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory; use PHPModelGenerator\Model\Validator\PropertyTemplateValidator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -33,7 +32,7 @@ public function modify( return; } - $nestedProperty = (new PropertyFactory(new PropertyProcessorFactory())) + $nestedProperty = (new PropertyFactory()) ->create( $schemaProcessor, $schema, diff --git a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php index 9fb62e0d..643199c0 100644 --- a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php @@ -18,7 +18,6 @@ use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ClearTypeHintDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; abstract class AbstractCompositionValidatorFactory extends AbstractValidatorFactory @@ -71,7 +70,7 @@ protected function getCompositionProperties( JsonSchema $propertySchema, bool $merged, ): array { - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); + $propertyFactory = new PropertyFactory(); $compositionProperties = []; $json = $propertySchema->getJson()['propertySchema']->getJson(); diff --git a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php index 94a45380..d17fa568 100644 --- a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php @@ -15,7 +15,6 @@ use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator; use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -51,7 +50,7 @@ public function modify( $propertySchema = $this->inheritPropertyType($propertySchema); $json = $propertySchema->getJson(); - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); + $propertyFactory = new PropertyFactory(); $onlyForDefinedValues = !($property instanceof BaseProperty) && (!$property->isRequired() diff --git a/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php index c22f9c6e..e6041c65 100644 --- a/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php @@ -16,7 +16,6 @@ use PHPModelGenerator\Model\Validator\SchemaDependencyValidator; use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; class PropertiesValidatorFactory extends AbstractValidatorFactory @@ -32,7 +31,7 @@ public function modify( ): void { $json = $propertySchema->getJson(); - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); + $propertyFactory = new PropertyFactory(); $json[$this->key] ??= []; // Setup empty properties for required properties which aren't defined in the properties section diff --git a/src/Model/Validator/PatternPropertiesValidator.php b/src/Model/Validator/PatternPropertiesValidator.php index ca7441dc..e9cd329b 100644 --- a/src/Model/Validator/PatternPropertiesValidator.php +++ b/src/Model/Validator/PatternPropertiesValidator.php @@ -11,7 +11,6 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -38,7 +37,7 @@ public function __construct( ) { $this->key = md5($propertyStructure->getJson()['key'] ?? $this->pattern); - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); + $propertyFactory = new PropertyFactory(); $this->validationProperty = $propertyFactory->create( $schemaProcessor, diff --git a/src/Model/Validator/PropertyNamesValidator.php b/src/Model/Validator/PropertyNamesValidator.php index b5fd76b2..b730141d 100644 --- a/src/Model/Validator/PropertyNamesValidator.php +++ b/src/Model/Validator/PropertyNamesValidator.php @@ -11,7 +11,6 @@ use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -47,7 +46,7 @@ public function __construct( ? $propertiesNames : $propertiesNames->withJson(['type' => 'string'] + $propertiesNames->getJson()); - $nameValidationProperty = (new PropertyFactory(new PropertyProcessorFactory())) + $nameValidationProperty = (new PropertyFactory()) ->create($schemaProcessor, $schema, 'property name', $propertiesNamesAsString) // the property name validator doesn't need type checks or required checks so simply filter them out ->filterValidators(static fn(Validator $validator): bool => diff --git a/src/PropertyProcessor/ProcessorFactoryInterface.php b/src/PropertyProcessor/ProcessorFactoryInterface.php deleted file mode 100644 index fcd7a8ad..00000000 --- a/src/PropertyProcessor/ProcessorFactoryInterface.php +++ /dev/null @@ -1,23 +0,0 @@ -isRequired() && !str_starts_with($property->getName(), 'item of array ')) { - $property->addValidator(new RequiredPropertyValidator($property), 1); - } - } - - /** - * Check if implicit null values are allowed for the given property (a not required property which has no - * explicit null type and is passed with a null value will be accepted) - */ - protected function isImplicitNullAllowed(PropertyInterface $property): bool - { - return $this->schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() && !$property->isRequired(); - } -} diff --git a/src/PropertyProcessor/Property/AbstractTypedValueProcessor.php b/src/PropertyProcessor/Property/AbstractTypedValueProcessor.php deleted file mode 100644 index 3f628ccb..00000000 --- a/src/PropertyProcessor/Property/AbstractTypedValueProcessor.php +++ /dev/null @@ -1,105 +0,0 @@ -getJson()['default'])) { - $this->setDefaultValue($property, $propertySchema->getJson()['default'], $propertySchema); - } - - return $property; - } - - /** - * @throws SchemaException - */ - public function setDefaultValue(PropertyInterface $property, mixed $default, JsonSchema $propertySchema): void - { - // allow integer default values for Number properties - if ($this instanceof NumberProcessor && is_int($default)) { - $default = (float) $default; - } - - if (!$this->getTypeCheckFunction()($default)) { - throw new SchemaException( - sprintf( - "Invalid type for default value of property %s in file %s", - $property->getName(), - $propertySchema->getFile(), - ) - ); - } - - $property->setDefaultValue($default); - } - - /** - * @inheritdoc - */ - protected function generateValidators(PropertyInterface $property, JsonSchema $propertySchema): void - { - parent::generateValidators($property, $propertySchema); - - // Skip adding TypeCheckValidator if a Draft modifier already added one for this type, - // preventing duplicate type-check validators during the bridge period. - foreach ($property->getValidators() as $validator) { - if ( - $validator->getValidator() instanceof TypeCheckInterface && - in_array(strtolower(static::TYPE), $validator->getValidator()->getTypes(), true) - ) { - return; - } - } - - $property->addValidator( - new TypeCheckValidator(static::TYPE, $property, $this->isImplicitNullAllowed($property)), - 2, - ); - } - - protected function getTypeCheck(): string - { - return $this->getTypeCheckFunction() . '($value) && '; - } - - private function getTypeCheckFunction(): string - { - return 'is_' . strtolower(static::TYPE); - } -} diff --git a/src/PropertyProcessor/Property/AbstractValueProcessor.php b/src/PropertyProcessor/Property/AbstractValueProcessor.php deleted file mode 100644 index 03b55d29..00000000 --- a/src/PropertyProcessor/Property/AbstractValueProcessor.php +++ /dev/null @@ -1,60 +0,0 @@ -getJson(); - - $property = (new Property( - $propertyName, - $this->type ? new PropertyType($this->type) : null, - $propertySchema, - $json['description'] ?? '', - )) - ->setRequired($this->required) - ->setReadOnly( - (isset($json['readOnly']) && $json['readOnly'] === true) || - $this->schemaProcessor->getGeneratorConfiguration()->isImmutable(), - ); - - $this->generateValidators($property, $propertySchema); - - return $property; - } -} diff --git a/src/PropertyProcessor/Property/AnyProcessor.php b/src/PropertyProcessor/Property/AnyProcessor.php deleted file mode 100644 index 1b797321..00000000 --- a/src/PropertyProcessor/Property/AnyProcessor.php +++ /dev/null @@ -1,27 +0,0 @@ -getJson()['default'])) { - $property->setDefaultValue($propertySchema->getJson()['default']); - } - - return $property; - } -} diff --git a/src/PropertyProcessor/Property/ArrayProcessor.php b/src/PropertyProcessor/Property/ArrayProcessor.php deleted file mode 100644 index 4c9cb7ec..00000000 --- a/src/PropertyProcessor/Property/ArrayProcessor.php +++ /dev/null @@ -1,15 +0,0 @@ -_rawModelDataInput), - array_keys($modelData), - ) - ), - )'; - - /** - * @inheritdoc - * - * @throws FileSystemException - * @throws SchemaException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - public function process(string $propertyName, JsonSchema $propertySchema): PropertyInterface - { - $this->schema - ->getSchemaDictionary() - ->setUpDefinitionDictionary($this->schemaProcessor, $this->schema); - - // create a property which is used to gather composed properties validators. - $property = new BaseProperty($propertyName, new PropertyType(static::TYPE), $propertySchema); - $this->generateValidators($property, $propertySchema); - - return $property; - } - - /** - * Add a validator to check all provided property names - * - * @throws SchemaException - * @throws FileSystemException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - protected function addPropertyNamesValidator(JsonSchema $propertySchema): void - { - if (!isset($propertySchema->getJson()['propertyNames'])) { - return; - } - - $this->schema->addBaseValidator( - new PropertyNamesValidator( - $this->schemaProcessor, - $this->schema, - $propertySchema->withJson($propertySchema->getJson()['propertyNames']), - ) - ); - } - - /** - * Add an object validator to specify constraints for properties which are not defined in the schema - * - * @throws FileSystemException - * @throws SchemaException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - protected function addAdditionalPropertiesValidator(JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if ( - !isset($json['additionalProperties']) && - $this->schemaProcessor->getGeneratorConfiguration()->denyAdditionalProperties() - ) { - $json['additionalProperties'] = false; - } - - if (!isset($json['additionalProperties']) || $json['additionalProperties'] === true) { - return; - } - - if (!is_bool($json['additionalProperties'])) { - $this->schema->addBaseValidator( - new AdditionalPropertiesValidator( - $this->schemaProcessor, - $this->schema, - $propertySchema, - ) - ); - - return; - } - - $this->schema->addBaseValidator( - new NoAdditionalPropertiesValidator( - new Property($this->schema->getClassName(), null, $propertySchema), - $json, - ) - ); - } - - /** - * @throws SchemaException - */ - protected function addPatternPropertiesValidator(JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!isset($json['patternProperties'])) { - return; - } - - foreach ($json['patternProperties'] as $pattern => $schema) { - $escapedPattern = addcslashes((string) $pattern, '/'); - - if (@preg_match("/$escapedPattern/", '') === false) { - throw new SchemaException( - "Invalid pattern '$pattern' for pattern property in file {$propertySchema->getFile()}", - ); - } - - $validator = new PatternPropertiesValidator( - $this->schemaProcessor, - $this->schema, - $pattern, - $propertySchema->withJson($schema), - ); - - $this->schema->addBaseValidator($validator); - } - } - - /** - * Add an object validator to limit the amount of provided properties - * - * @throws SchemaException - */ - protected function addMaxPropertiesValidator(string $propertyName, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!isset($json['maxProperties'])) { - return; - } - - $this->schema->addBaseValidator( - new PropertyValidator( - new Property($propertyName, null, $propertySchema), - sprintf( - '%s > %d', - self::COUNT_PROPERTIES, - $json['maxProperties'], - ), - MaxPropertiesException::class, - [$json['maxProperties']], - ) - ); - } - - /** - * Add an object validator to force at least the defined amount of properties to be provided - * - * @throws SchemaException - */ - protected function addMinPropertiesValidator(string $propertyName, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!isset($json['minProperties'])) { - return; - } - - $this->schema->addBaseValidator( - new PropertyValidator( - new Property($propertyName, null, $propertySchema), - sprintf( - '%s < %d', - self::COUNT_PROPERTIES, - $json['minProperties'], - ), - MinPropertiesException::class, - [$json['minProperties']], - ) - ); - } - - /** - * Add the properties defined in the JSON schema to the current schema model - * - * @throws SchemaException - */ - protected function addPropertiesToSchema(JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); - - $json['properties'] ??= []; - // setup empty properties for required properties which aren't defined in the properties section of the schema - $json['properties'] += array_fill_keys( - array_diff($json['required'] ?? [], array_keys($json['properties'])), - [], - ); - - foreach ($json['properties'] as $propertyName => $propertyStructure) { - if ($propertyStructure === false) { - if (in_array($propertyName, $json['required'] ?? [], true)) { - throw new SchemaException( - sprintf( - "Property '%s' is denied (schema false) but also listed as required in file %s", - $propertyName, - $propertySchema->getFile(), - ), - ); - } - - $this->schema->addBaseValidator( - new PropertyValidator( - new Property($propertyName, null, $propertySchema->withJson([])), - "array_key_exists('" . addslashes($propertyName) . "', \$modelData)", - DeniedPropertyException::class, - ) - ); - continue; - } - - $required = in_array($propertyName, $json['required'] ?? [], true); - $dependencies = $json['dependencies'][$propertyName] ?? null; - $property = $propertyFactory->create( - $this->schemaProcessor, - $this->schema, - (string) $propertyName, - $propertySchema->withJson( - $dependencies !== null - ? $propertyStructure + ['_dependencies' => $dependencies] - : $propertyStructure, - ), - $required, - ); - - if ($dependencies !== null) { - $this->addDependencyValidator($property, $dependencies); - } - - $this->schema->addProperty($property); - } - } - - /** - * @throws SchemaException - */ - private function addDependencyValidator(PropertyInterface $property, array $dependencies): void - { - // check if we have a simple list of properties which must be present if the current property is present - $propertyDependency = true; - - array_walk( - $dependencies, - static function ($dependency, $index) use (&$propertyDependency): void { - $propertyDependency = $propertyDependency && is_int($index) && is_string($dependency); - }, - ); - - if ($propertyDependency) { - $property->addValidator(new PropertyDependencyValidator($property, $dependencies)); - - return; - } - - if (!isset($dependencies['type'])) { - $dependencies['type'] = 'object'; - } - - $dependencySchema = $this->schemaProcessor->processSchema( - new JsonSchema($this->schema->getJsonSchema()->getFile(), $dependencies), - $this->schema->getClassPath(), - "{$this->schema->getClassName()}_{$property->getName()}_Dependency", - $this->schema->getSchemaDictionary(), - ); - - $property->addValidator(new SchemaDependencyValidator($this->schemaProcessor, $property, $dependencySchema)); - $this->schema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($dependencySchema)); - - $this->transferDependentPropertiesToBaseSchema($dependencySchema); - } - - /** - * Transfer all properties from $dependencySchema to the base schema of the current property - */ - private function transferDependentPropertiesToBaseSchema(Schema $dependencySchema): void - { - foreach ($dependencySchema->getProperties() as $property) { - $this->schema->addProperty( - // validators and types must not be transferred as any value is acceptable for the property if the - // property defining the dependency isn't present - (clone $property) - ->setRequired(false) - ->setType(null) - ->filterValidators(static fn(): bool => false), - ); - } - } - - /** - * Transfer properties of composed properties to the current schema to offer a complete model including all - * composed properties. - * - * @throws SchemaException - */ - protected function transferComposedPropertiesToSchema(PropertyInterface $property): void - { - foreach ($property->getValidators() as $validator) { - $validator = $validator->getValidator(); - - if (!is_a($validator, AbstractComposedPropertyValidator::class)) { - continue; - } - - // If the transferred validator of the composed property is also a composed property strip the nested - // composition validations from the added validator. The nested composition will be validated in the object - // generated for the nested composition which will be executed via an instantiation. Consequently, the - // validation must not be executed in the outer composition. - $this->schema->addBaseValidator( - ($validator instanceof ComposedPropertyValidator) - ? $validator->withoutNestedCompositionValidation() - : $validator, - ); - - if ( - !is_a( - $validator->getCompositionProcessor(), - ComposedPropertiesValidatorFactoryInterface::class, - true, - ) - ) { - continue; - } - - $branchesForValidator = $validator instanceof ConditionalPropertyValidator - ? $validator->getConditionBranches() - : $validator->getComposedProperties(); - - $totalBranches = count($branchesForValidator); - $resolvedPropertiesCallbacks = 0; - $seenBranchPropertyNames = []; - - foreach ($validator->getComposedProperties() as $composedProperty) { - $composedProperty->onResolve(function () use ( - $composedProperty, - $property, - $validator, - $branchesForValidator, - $totalBranches, - &$resolvedPropertiesCallbacks, - &$seenBranchPropertyNames, - ): void { - if (!$composedProperty->getNestedSchema()) { - throw new SchemaException( - sprintf( - "No nested schema for composed property %s in file %s found", - $property->getName(), - $property->getJsonSchema()->getFile(), - ) - ); - } - - $isBranchForValidator = in_array($composedProperty, $branchesForValidator, true); - - $composedProperty->getNestedSchema()->onAllPropertiesResolved( - function () use ( - $composedProperty, - $validator, - $isBranchForValidator, - $totalBranches, - &$resolvedPropertiesCallbacks, - &$seenBranchPropertyNames, - ): void { - foreach ($composedProperty->getNestedSchema()->getProperties() as $branchProperty) { - $this->schema->addProperty( - $this->cloneTransferredProperty( - $branchProperty, - $composedProperty, - $validator, - ), - $validator->getCompositionProcessor(), - ); - - $composedProperty->appendAffectedObjectProperty($branchProperty); - $seenBranchPropertyNames[$branchProperty->getName()] = true; - } - - if ($isBranchForValidator && ++$resolvedPropertiesCallbacks === $totalBranches) { - foreach (array_keys($seenBranchPropertyNames) as $branchPropertyName) { - $this->schema->getPropertyMerger()->checkForTotalConflict( - $branchPropertyName, - $totalBranches, - ); - } - } - }, - ); - }); - } - } - } - - /** - * Clone the provided property to transfer it to a schema. Sets the nullability and required flag based on the - * composition processor used to set up the composition. Widens the type to mixed when the property is exclusive - * to one anyOf/oneOf branch and at least one other branch allows additional properties, preventing TypeError when - * raw input values of an arbitrary type are stored in the property slot. - */ - private function cloneTransferredProperty( - PropertyInterface $property, - CompositionPropertyDecorator $sourceBranch, - AbstractComposedPropertyValidator $validator, - ): PropertyInterface { - $compositionProcessor = $validator->getCompositionProcessor(); - - $transferredProperty = (clone $property) - ->filterValidators(static fn(Validator $validator): bool => - is_a($validator->getValidator(), PropertyTemplateValidator::class)); - - if (!is_a($compositionProcessor, AllOfValidatorFactory::class, true)) { - $transferredProperty->setRequired(false); - - if ($transferredProperty->getType()) { - $transferredProperty->setType( - new PropertyType($transferredProperty->getType()->getNames(), true), - new PropertyType($transferredProperty->getType(true)->getNames(), true), - ); - } - - $wideningBranches = $validator instanceof ConditionalPropertyValidator - ? $validator->getConditionBranches() - : $validator->getComposedProperties(); - - if ($this->exclusiveBranchPropertyNeedsWidening($property->getName(), $sourceBranch, $wideningBranches)) { - $transferredProperty->setType(null, null, reset: true); - } - } - - return $transferredProperty; - } - - /** - * Returns true when the property named $propertyName is exclusive to $sourceBranch and at least - * one other anyOf/oneOf branch allows additional properties (i.e. does NOT declare - * additionalProperties: false). In that case the property slot can receive an arbitrarily-typed - * raw input value from a non-matching branch, so the native type hint must be removed. - * - * Returns false when the property appears in another branch too (Schema::addProperty handles - * that via type merging) or when all other branches have additionalProperties: false (making - * the property mutually exclusive with the other branches' properties). - * - * @param CompositionPropertyDecorator[] $allBranches - */ - private function exclusiveBranchPropertyNeedsWidening( - string $propertyName, - CompositionPropertyDecorator $sourceBranch, - array $allBranches, - ): bool { - // Pass 1: if any other branch defines the same property, Phase 6 handles the type - // merging via Schema::addProperty — widening to mixed is not needed here. - foreach ($allBranches as $branch) { - if ($branch === $sourceBranch) { - continue; - } - - $branchPropertyNames = $branch->getNestedSchema() - ? array_map( - static fn(PropertyInterface $p): string => $p->getName(), - $branch->getNestedSchema()->getProperties(), - ) - : []; - - if (in_array($propertyName, $branchPropertyNames, true)) { - return false; - } - } - - // Pass 2: the property is exclusive to $sourceBranch. Widening is needed when at - // least one other branch allows additional properties — an arbitrary input value can - // then land in this slot when that branch is the one that matched. - foreach ($allBranches as $branch) { - if ($branch === $sourceBranch) { - continue; - } - - if (($branch->getBranchSchema()->getJson()['additionalProperties'] ?? true) !== false) { - return true; - } - } - - // All other branches have additionalProperties:false — no arbitrary value can arrive. - return false; - } -} diff --git a/src/PropertyProcessor/Property/BasereferenceProcessor.php b/src/PropertyProcessor/Property/BasereferenceProcessor.php deleted file mode 100644 index 518a6605..00000000 --- a/src/PropertyProcessor/Property/BasereferenceProcessor.php +++ /dev/null @@ -1,48 +0,0 @@ -schema - ->getSchemaDictionary() - ->setUpDefinitionDictionary($this->schemaProcessor, $this->schema); - - $property = parent::process($propertyName, $propertySchema); - - if (!$property->getNestedSchema()) { - throw new SchemaException( - sprintf( - 'A referenced schema on base level must provide an object definition for property %s in file %s', - $propertyName, - $propertySchema->getFile(), - ) - ); - } - - foreach ($property->getNestedSchema()->getProperties() as $propertiesOfReferencedObject) { - $this->schema->addProperty($propertiesOfReferencedObject); - } - - return $property; - } -} diff --git a/src/PropertyProcessor/Property/BooleanProcessor.php b/src/PropertyProcessor/Property/BooleanProcessor.php deleted file mode 100644 index 2d6bc48d..00000000 --- a/src/PropertyProcessor/Property/BooleanProcessor.php +++ /dev/null @@ -1,15 +0,0 @@ -setType(null) - ->addTypeHintDecorator(new TypeHintDecorator(['null'])); - } -} diff --git a/src/PropertyProcessor/Property/NumberProcessor.php b/src/PropertyProcessor/Property/NumberProcessor.php deleted file mode 100644 index f5116548..00000000 --- a/src/PropertyProcessor/Property/NumberProcessor.php +++ /dev/null @@ -1,27 +0,0 @@ -addDecorator(new IntToFloatCastDecorator()); - } -} diff --git a/src/PropertyProcessor/Property/ObjectProcessor.php b/src/PropertyProcessor/Property/ObjectProcessor.php deleted file mode 100644 index fb40f886..00000000 --- a/src/PropertyProcessor/Property/ObjectProcessor.php +++ /dev/null @@ -1,81 +0,0 @@ -schemaProcessor->getGeneratorConfiguration()->getClassNameGenerator()->getClassName( - $propertyName, - $propertySchema, - false, - $this->schemaProcessor->getCurrentClassName(), - ); - - $schema = $this->schemaProcessor->processSchema( - $propertySchema, - $this->schemaProcessor->getCurrentClassPath(), - $className, - $this->schema->getSchemaDictionary(), - ); - - // if the generated schema is located in a different namespace (the schema for the given structure in - // $propertySchema is duplicated) add used classes to the current schema. By importing the class which is - // represented by $schema and by transferring all imports of $schema as well as imports for all properties - // of $schema to $this->schema the already generated schema can be used - if ( - $schema->getClassPath() !== $this->schema->getClassPath() || - $schema->getClassName() !== $this->schema->getClassName() - ) { - $this->schema->addUsedClass( - join( - '\\', - array_filter([ - $this->schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(), - $schema->getClassPath(), - $schema->getClassName(), - ]), - ) - ); - - $this->schema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($schema)); - } - - $property - ->addDecorator( - new ObjectInstantiationDecorator( - $schema->getClassName(), - $this->schemaProcessor->getGeneratorConfiguration(), - ) - ) - ->setType(new PropertyType($schema->getClassName())) - ->setNestedSchema($schema); - - return $property->addValidator(new InstanceOfValidator($property), 3); - } -} diff --git a/src/PropertyProcessor/Property/ReferenceProcessor.php b/src/PropertyProcessor/Property/ReferenceProcessor.php deleted file mode 100644 index 907d9b59..00000000 --- a/src/PropertyProcessor/Property/ReferenceProcessor.php +++ /dev/null @@ -1,81 +0,0 @@ -getJson()['$ref']; - $dictionary = $this->schema->getSchemaDictionary(); - - try { - $definition = $dictionary->getDefinition($reference, $this->schemaProcessor, $path); - - if ($definition) { - $definitionSchema = $definition->getSchema(); - - if ( - $this->schema->getClassPath() !== $definitionSchema->getClassPath() || - $this->schema->getClassName() !== $definitionSchema->getClassName() || - ( - $this->schema->getClassName() === 'ExternalSchema' && - $definitionSchema->getClassName() === 'ExternalSchema' - ) - ) { - $this->schema->addNamespaceTransferDecorator( - new SchemaNamespaceTransferDecorator($definitionSchema), - ); - - // When the definition resolves to a canonical (non-ExternalSchema) class that - // lives in a different namespace from the current schema, register its FQCN - // directly as a used class. The ExternalSchema intermediary that previously - // performed this registration (transitively via its own usedClasses list) is - // no longer created when the file was already processed; this explicit call - // ensures the referencing schema's import list remains complete. - if ($definitionSchema->getClassName() !== 'ExternalSchema') { - $this->schema->addUsedClass(join('\\', array_filter([ - $this->schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(), - $definitionSchema->getClassPath(), - $definitionSchema->getClassName(), - ]))); - } - } - - return $definition->resolveReference( - $propertyName, - $path, - $this->required, - $propertySchema->getJson()['_dependencies'] ?? null, - ); - } - } catch (Exception $exception) { - throw new SchemaException( - "Unresolved Reference $reference in file {$propertySchema->getFile()}", - 0, - $exception, - ); - } - - throw new SchemaException("Unresolved Reference $reference in file {$propertySchema->getFile()}"); - } -} diff --git a/src/PropertyProcessor/Property/StringProcessor.php b/src/PropertyProcessor/Property/StringProcessor.php deleted file mode 100644 index 7ae4db10..00000000 --- a/src/PropertyProcessor/Property/StringProcessor.php +++ /dev/null @@ -1,15 +0,0 @@ -getJson(); - // redirect properties with a constant value to the ConstProcessor - if (isset($json['const'])) { - $json['type'] = 'const'; - } - // redirect references to the ReferenceProcessor + // $ref: replace the property entirely via the definition dictionary. + // This is a schema-identity primitive — it cannot be a Draft modifier because + // ModifierInterface::modify returns void and cannot replace the property object. if (isset($json['$ref'])) { - $json['type'] = isset($json['type']) && $json['type'] === 'base' - ? 'baseReference' - : 'reference'; + if (isset($json['type']) && $json['type'] === 'base') { + return $this->processBaseReference( + $schemaProcessor, + $schema, + $propertyName, + $propertySchema, + $required, + ); + } + + return $this->processReference($schemaProcessor, $schema, $propertyName, $propertySchema, $required); } $resolvedType = $json['type'] ?? 'any'; @@ -75,131 +81,262 @@ public function create( $this->checkType($resolvedType, $schema); - // Nested object properties: bypass the legacy ObjectProcessor. Call processSchema to - // generate the nested class, store it on the property, then run Draft modifiers with - // the nested Schema as $schema so keyword modifiers add to the correct target. - if ($resolvedType === 'object') { - $json = $propertySchema->getJson(); - $property = (new Property( + return match ($resolvedType) { + 'object' => $this->createObjectProperty( + $schemaProcessor, + $schema, $propertyName, - null, $propertySchema, - $json['description'] ?? '', - )) - ->setRequired($required) - ->setReadOnly( - (isset($json['readOnly']) && $json['readOnly'] === true) || - $schemaProcessor->getGeneratorConfiguration()->isImmutable(), - ); - - if ($required && !str_starts_with($propertyName, 'item of array ')) { - $property->addValidator(new RequiredPropertyValidator($property), 1); - } - - $className = $schemaProcessor->getGeneratorConfiguration()->getClassNameGenerator()->getClassName( + $required, + ), + 'base' => $this->createBaseProperty($schemaProcessor, $schema, $propertyName, $propertySchema), + default => $this->createTypedProperty( + $schemaProcessor, + $schema, $propertyName, $propertySchema, - false, - $schemaProcessor->getCurrentClassName(), - ); - - // Strip property-level keywords (filter, enum, default) before passing the schema to - // processSchema. These keywords target the outer property — not the nested class root — - // and are handled by applyUniversalModifiers below after processSchema returns. - $nestedJson = $json; - unset($nestedJson['filter'], $nestedJson['enum'], $nestedJson['default']); - $nestedSchema = $schemaProcessor->processSchema( - $propertySchema->withJson($nestedJson), - $schemaProcessor->getCurrentClassPath(), - $className, - $schema->getSchemaDictionary(), - ); + $resolvedType, + $required, + ), + }; + } - if ($nestedSchema !== null) { - // Store on the property so ObjectModifier can read it. - $property->setNestedSchema($nestedSchema); + /** + * Handle a nested object property: generate the nested class, wire the outer property, + * then apply universal modifiers (filter, enum, default, const) on the outer property. + * + * @throws SchemaException + */ + private function createObjectProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + bool $required, + ): PropertyInterface { + $json = $propertySchema->getJson(); + $property = $this->buildProperty($schemaProcessor, $propertyName, null, $propertySchema, $required); - // processSchema already ran all schema-targeting Draft modifiers (PropertiesModifier, - // PatternPropertiesModifier, etc.) on the nested schema internally via the type=base - // path. Here we only wire the outer property: add the type-check validator and the - // instantiation linkage. Passing the outer $schema ensures addUsedClass and - // addNamespaceTransferDecorator target the correct parent class. - $this->wireObjectProperty($schemaProcessor, $schema, $property, $propertySchema); - } + $className = $schemaProcessor->getGeneratorConfiguration()->getClassNameGenerator()->getClassName( + $propertyName, + $propertySchema, + false, + $schemaProcessor->getCurrentClassName(), + ); - // Universal modifiers (filter, enum, default) must still run on the outer property - // with the outer $schema context. They are property-targeting, not schema-targeting, - // so they must not be applied inside processSchema (which targets the nested class). - $this->applyUniversalModifiers($schemaProcessor, $schema, $property, $propertySchema); + // Strip property-level keywords before passing the schema to processSchema: these keywords + // target the outer property and are handled by the universal modifiers below. + $nestedJson = $json; + unset($nestedJson['filter'], $nestedJson['enum'], $nestedJson['default']); + $nestedSchema = $schemaProcessor->processSchema( + $propertySchema->withJson($nestedJson), + $schemaProcessor->getCurrentClassPath(), + $className, + $schema->getSchemaDictionary(), + ); - return $property; + if ($nestedSchema !== null) { + $property->setNestedSchema($nestedSchema); + $this->wireObjectProperty($schemaProcessor, $schema, $property, $propertySchema); } - // Root-level schema: run the legacy BaseProcessor bridge (handles setUpDefinitionDictionary - // and composition validators), then Draft modifiers (PropertiesModifier etc. — must run - // BEFORE transferComposedPropertiesToSchema so root properties are registered first and - // the allOf merger can narrow them correctly), then composition property transfer. - // ObjectModifier skips for BaseProperty instances. - // The propertySchema is rewritten from type=base to type=object so applyDraftModifiers - // correctly resolves the 'object' modifier list from the Draft. - if ($resolvedType === 'base') { - $property = $this->processorFactory - ->getProcessor('base', $schemaProcessor, $schema, $required) - ->process($propertyName, $propertySchema); - - $objectJson = $json; - $objectJson['type'] = 'object'; - $this->applyDraftModifiers($schemaProcessor, $schema, $property, $propertySchema->withJson($objectJson)); - - $schemaProcessor->transferComposedPropertiesToSchema($property, $schema); - - return $property; - } + // Universal modifiers (filter, enum, default, const) run on the outer property. + $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema, anyOnly: true); - $property = $this->processorFactory - ->getProcessor( - $resolvedType, - $schemaProcessor, - $schema, - $required, - ) - ->process($propertyName, $propertySchema); + return $property; + } + + /** + * Handle a root-level schema (type=base): set up definitions, run all Draft modifiers, + * then transfer any composed properties to the schema. + * + * @throws SchemaException + */ + private function createBaseProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + ): PropertyInterface { + $schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema); + $property = new BaseProperty($propertyName, new PropertyType('object'), $propertySchema); + + $objectJson = $propertySchema->getJson(); + $objectJson['type'] = 'object'; + $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema->withJson($objectJson)); - $this->applyDraftModifiers($schemaProcessor, $schema, $property, $propertySchema); + $schemaProcessor->transferComposedPropertiesToSchema($property, $schema); return $property; } /** - * Handle "type": [...] properties by processing each type through its legacy processor, - * merging validators and decorators onto a single property, then consolidating type checks. - * - * @param string[] $types + * Handle scalar, array, and untyped properties: construct directly and run all Draft modifiers. * * @throws SchemaException */ - private function createMultiTypeProperty( + private function createTypedProperty( SchemaProcessor $schemaProcessor, Schema $schema, string $propertyName, JsonSchema $propertySchema, - array $types, + string $type, bool $required, ): PropertyInterface { - $json = $propertySchema->getJson(); - - $property = (new Property( + $phpType = $type !== 'any' ? TypeConverter::jsonSchemaToPhp($type) : null; + $property = $this->buildProperty( + $schemaProcessor, $propertyName, - null, + $phpType !== null ? new PropertyType($phpType) : null, $propertySchema, - $json['description'] ?? '', - )) + $required, + ); + + $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema); + + return $property; + } + + /** + * Construct a Property with the common required/readOnly setup. + */ + private function buildProperty( + SchemaProcessor $schemaProcessor, + string $propertyName, + ?PropertyType $type, + JsonSchema $propertySchema, + bool $required, + ): Property { + $json = $propertySchema->getJson(); + + $property = (new Property($propertyName, $type, $propertySchema, $json['description'] ?? '')) ->setRequired($required) ->setReadOnly( (isset($json['readOnly']) && $json['readOnly'] === true) || $schemaProcessor->getGeneratorConfiguration()->isImmutable(), ); + if ($required && !str_starts_with($propertyName, 'item of array ')) { + $property->addValidator(new RequiredPropertyValidator($property), 1); + } + + return $property; + } + + /** + * Resolve a $ref reference by looking it up in the definition dictionary. + * + * @throws SchemaException + */ + private function processReference( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + bool $required, + ): PropertyInterface { + $path = []; + $reference = $propertySchema->getJson()['$ref']; + $dictionary = $schema->getSchemaDictionary(); + + try { + $definition = $dictionary->getDefinition($reference, $schemaProcessor, $path); + + if ($definition) { + $definitionSchema = $definition->getSchema(); + + if ( + $schema->getClassPath() !== $definitionSchema->getClassPath() || + $schema->getClassName() !== $definitionSchema->getClassName() || + ( + $schema->getClassName() === 'ExternalSchema' && + $definitionSchema->getClassName() === 'ExternalSchema' + ) + ) { + $schema->addNamespaceTransferDecorator( + new SchemaNamespaceTransferDecorator($definitionSchema), + ); + + if ($definitionSchema->getClassName() !== 'ExternalSchema') { + $schema->addUsedClass(join('\\', array_filter([ + $schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(), + $definitionSchema->getClassPath(), + $definitionSchema->getClassName(), + ]))); + } + } + + return $definition->resolveReference( + $propertyName, + $path, + $required, + $propertySchema->getJson()['_dependencies'] ?? null, + ); + } + } catch (Exception $exception) { + throw new SchemaException( + "Unresolved Reference $reference in file {$propertySchema->getFile()}", + 0, + $exception, + ); + } + + throw new SchemaException("Unresolved Reference $reference in file {$propertySchema->getFile()}"); + } + + /** + * Resolve a $ref on a base-level schema: set up definitions, delegate to processReference, + * then copy the referenced schema's properties to the parent schema. + * + * @throws SchemaException + */ + private function processBaseReference( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + bool $required, + ): PropertyInterface { + $schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema); + + $property = $this->processReference($schemaProcessor, $schema, $propertyName, $propertySchema, $required); + + if (!$property->getNestedSchema()) { + throw new SchemaException( + sprintf( + 'A referenced schema on base level must provide an object definition for property %s in file %s', + $propertyName, + $propertySchema->getFile(), + ) + ); + } + + foreach ($property->getNestedSchema()->getProperties() as $propertiesOfReferencedObject) { + $schema->addProperty($propertiesOfReferencedObject); + } + + return $property; + } + + /** + * Handle "type": [...] properties by processing each type through its Draft modifiers, + * merging validators and decorators onto a single property, then consolidating type checks. + * + * @param string[] $types + * + * @throws SchemaException + */ + private function createMultiTypeProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + array $types, + bool $required, + ): PropertyInterface { + $json = $propertySchema->getJson(); + $property = $this->buildProperty($schemaProcessor, $propertyName, null, $propertySchema, $required); + $collectedTypes = []; $typeHints = []; $resolvedSubCount = 0; @@ -216,16 +353,18 @@ private function createMultiTypeProperty( $subJson['type'] = $type; $subSchema = $propertySchema->withJson($subJson); - $subProperty = $this->processorFactory - ->getProcessor($type, $schemaProcessor, $schema, $required) - ->process($propertyName, $subSchema); - - // For type=object, ObjectProcessor::process already called processSchema (which ran - // all schema-targeting Draft modifiers) and wired the property. Running - // applyTypeModifiers would re-apply PropertiesModifier etc. to the outer $schema. - if ($type !== 'object') { - $this->applyTypeModifiers($schemaProcessor, $schema, $subProperty, $subSchema); - } + // For type=object, delegate to the same object path (processSchema + wireObjectProperty). + $subProperty = $type === 'object' + ? $this->createObjectProperty($schemaProcessor, $schema, $propertyName, $subSchema, $required) + : $this->createSubTypeProperty( + $schemaProcessor, + $schema, + $propertyName, + $subSchema, + $type, + $required, + $json, + ); $subProperty->onResolve(function () use ( $property, @@ -273,6 +412,34 @@ private function createMultiTypeProperty( return $property; } + /** + * Build a non-object sub-property for a multi-type array, applying only type-specific + * modifiers (no universal 'any' modifiers — those run once on the parent after finalization). + * + * @throws SchemaException + */ + private function createSubTypeProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + string $type, + bool $required, + array $parentJson, + ): Property { + $subProperty = $this->buildProperty( + $schemaProcessor, + $propertyName, + new PropertyType(TypeConverter::jsonSchemaToPhp($type)), + $propertySchema, + $required, + ); + + $this->applyModifiers($schemaProcessor, $schema, $subProperty, $propertySchema, anyOnly: false, typeOnly: true); + + return $subProperty; + } + /** * Called once all sub-properties of a multi-type property have resolved. * Adds the consolidated MultiTypeCheckValidator, sets the union PropertyType, @@ -314,13 +481,13 @@ private function finalizeMultiTypeProperty( $property->addTypeHintDecorator(new TypeHintDecorator($typeHints)); - $this->applyUniversalModifiers($schemaProcessor, $schema, $property, $propertySchema); + $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema, anyOnly: true); } /** * Wire the outer property for a nested object: add the type-check validator and instantiation - * linkage. Schema-targeting modifiers (PropertiesModifier etc.) are intentionally NOT run here - * because processSchema already applied them to the nested schema via the type=base path. + * linkage. Schema-targeting modifiers are intentionally NOT run here because processSchema + * already applied them to the nested schema. * * @throws SchemaException */ @@ -341,90 +508,42 @@ private function wireObjectProperty( } /** - * Run only the type-specific Draft modifiers (no universal 'any' modifiers) for the given - * property. + * Run Draft modifiers for the given property. + * + * By default all covered types (type-specific + 'any') run. Pass $anyOnly=true to run + * only the 'any' entry (used for object outer-property universal keywords), or $typeOnly=true + * to run only type-specific entries (used for multi-type sub-properties). * * @throws SchemaException */ - private function applyTypeModifiers( + private function applyModifiers( SchemaProcessor $schemaProcessor, Schema $schema, PropertyInterface $property, JsonSchema $propertySchema, + bool $anyOnly = false, + bool $typeOnly = false, ): void { - $type = $propertySchema->getJson()['type'] ?? 'any'; + $type = $propertySchema->getJson()['type'] ?? 'any'; $builtDraft = $this->resolveBuiltDraft($schemaProcessor, $propertySchema); - if ($type === 'any' || !$builtDraft->hasType($type)) { - return; - } - - foreach ($builtDraft->getCoveredTypes($type) as $coveredType) { - if ($coveredType->getType() === 'any') { - continue; - } - - foreach ($coveredType->getModifiers() as $modifier) { - $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); - } - } - } + // For untyped properties ('any'), only run the 'any' entry — getCoveredTypes('any') + // returns all types, which would incorrectly apply type-specific modifiers. + $coveredTypes = $type === 'any' + ? array_filter($builtDraft->getCoveredTypes('any'), static fn($t) => $t->getType() === 'any') + : $builtDraft->getCoveredTypes($type); - /** - * Run only the universal ('any') Draft modifiers for the given property. - * - * @throws SchemaException - */ - private function applyUniversalModifiers( - SchemaProcessor $schemaProcessor, - Schema $schema, - PropertyInterface $property, - JsonSchema $propertySchema, - ): void { - $builtDraft = $this->resolveBuiltDraft($schemaProcessor, $propertySchema); + foreach ($coveredTypes as $coveredType) { + $isAnyEntry = $coveredType->getType() === 'any'; - foreach ($builtDraft->getCoveredTypes('any') as $coveredType) { - if ($coveredType->getType() !== 'any') { + if ($anyOnly && !$isAnyEntry) { continue; } - foreach ($coveredType->getModifiers() as $modifier) { - $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); + if ($typeOnly && $isAnyEntry) { + continue; } - } - } - - /** - * Run all Draft modifiers (type-specific and universal) for the given property. - * - * @throws SchemaException - */ - private function applyDraftModifiers( - SchemaProcessor $schemaProcessor, - Schema $schema, - PropertyInterface $property, - JsonSchema $propertySchema, - ): void { - $type = $propertySchema->getJson()['type'] ?? 'any'; - $builtDraft = $this->resolveBuiltDraft($schemaProcessor, $propertySchema); - // Types not declared in the draft are internal routing signals (e.g. 'allOf', 'base', - // 'reference'). They have no draft modifiers to apply. - if ($type !== 'any' && !$builtDraft->hasType($type)) { - return; - } - - // For untyped properties ('any'), only run universal modifiers (the 'any' entry itself). - // getCoveredTypes('any') returns all types — that would incorrectly apply type-specific - // modifiers (e.g. TypeCheckModifier) to properties that carry no type constraint. - $coveredTypes = $type === 'any' - ? array_filter( - $builtDraft->getCoveredTypes('any'), - static fn($t) => $t->getType() === 'any', - ) - : $builtDraft->getCoveredTypes($type); - - foreach ($coveredTypes as $coveredType) { foreach ($coveredType->getModifiers() as $modifier) { $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); } diff --git a/src/PropertyProcessor/PropertyProcessorFactory.php b/src/PropertyProcessor/PropertyProcessorFactory.php deleted file mode 100644 index aa569831..00000000 --- a/src/PropertyProcessor/PropertyProcessorFactory.php +++ /dev/null @@ -1,40 +0,0 @@ -getJsonSchema()->getFile(), - ) - ); - } - - return new $processor($schemaProcessor, $schema, $required); - } -} diff --git a/src/PropertyProcessor/PropertyProcessorInterface.php b/src/PropertyProcessor/PropertyProcessorInterface.php deleted file mode 100644 index fb176ed7..00000000 --- a/src/PropertyProcessor/PropertyProcessorInterface.php +++ /dev/null @@ -1,24 +0,0 @@ -getJson(); $json['type'] = 'base'; - (new PropertyFactory(new PropertyProcessorFactory()))->create( + (new PropertyFactory())->create( $this, $schema, $className, diff --git a/tests/Objects/ConstPropertyTest.php b/tests/Objects/ConstPropertyTest.php index 54827b39..69616c86 100644 --- a/tests/Objects/ConstPropertyTest.php +++ b/tests/Objects/ConstPropertyTest.php @@ -226,7 +226,7 @@ public function testProvidedConstPropertiesIsValidWithDifferentImplicitNull( ): void { $className = $this->generateClassFromFile( 'RequiredAndOptionalConstProperties.json', - new GeneratorConfiguration(), + (new GeneratorConfiguration())->setImmutable(false), false, $implicitNull, ); @@ -244,8 +244,7 @@ public function testProvidedConstPropertiesIsValidWithDifferentImplicitNull( $this->assertSame('string', $returnType->getName()); $this->assertFalse($returnType->allowsNull()); - $this->assertSame('string', $this->getParameterTypeAnnotation($className, 'setRequiredProperty'), - ); + $this->assertSame('string', $this->getParameterTypeAnnotation($className, 'setRequiredProperty'),); $setAgeParamType = $this->getParameterType($className, 'setRequiredProperty'); $this->assertSame('string', $setAgeParamType->getName()); $this->assertFalse($returnType->allowsNull()); @@ -303,8 +302,7 @@ public function testNotMatchingRequiredAndOptionalProvidedDataThrowsAnException( string $reqPropertyValue, ?string $optPropertyValue, string $exceptionMessage - ): void - { + ): void { $className = $this->generateClassFromFile( 'RequiredAndOptionalConstProperties.json', new GeneratorConfiguration(), @@ -332,6 +330,18 @@ public static function invalidRequiredAndOptionalConstPropertiesDataProvider(): ); } + public function testConstPropertyHasNoSetterWhenImmutable(): void + { + $className = $this->generateClassFromFile('RequiredAndOptionalConstProperties.json'); + + $object = new $className(['requiredProperty' => 'red']); + + $this->assertTrue(is_callable([$object, 'getRequiredProperty'])); + $this->assertFalse(is_callable([$object, 'setRequiredProperty'])); + $this->assertTrue(is_callable([$object, 'getOptionalProperty'])); + $this->assertFalse(is_callable([$object, 'setOptionalProperty'])); + } + #[DataProvider('implicitNullDataProvider')] public function testProvidedNullValueConstPropertyIsValid(bool $implicitNull): void { diff --git a/tests/PropertyProcessor/PropertyProcessorFactoryTest.php b/tests/PropertyProcessor/PropertyProcessorFactoryTest.php deleted file mode 100644 index e8ae6210..00000000 --- a/tests/PropertyProcessor/PropertyProcessorFactoryTest.php +++ /dev/null @@ -1,91 +0,0 @@ -getProcessor( - $type, - new SchemaProcessor( - new RecursiveDirectoryProvider(__DIR__), - '', - new GeneratorConfiguration(), - new RenderQueue(), - ), - new Schema('', '', '', new JsonSchema('', [])), - ); - - $this->assertInstanceOf($expectedClass, $propertyProcessor); - } - - /** - * Provide valid properties which must result in a PropertyProcessor - */ - public static function validPropertyProvider(): array - { - return [ - 'array' => ['array', ArrayProcessor::class], - 'boolean' => ['boolean', BooleanProcessor::class], - 'integer' => ['integer', IntegerProcessor::class], - 'null' => ['null', NullProcessor::class], - 'number' => ['number', NumberProcessor::class], - 'object' => ['object', ObjectProcessor::class], - 'string' => ['string', StringProcessor::class] - ]; - } - - /** - * @throws SchemaException - */ - public function testGetInvalidPropertyProcessorThrowsAnException(): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessage('Unsupported property type Hello'); - - $propertyProcessorFactory = new PropertyProcessorFactory(); - - $propertyProcessorFactory->getProcessor( - 'Hello', - new SchemaProcessor( - new RecursiveDirectoryProvider(__DIR__), - '', - new GeneratorConfiguration(), - new RenderQueue(), - ), - new Schema('', '', '', new JsonSchema('', [])), - ); - } -} From cb1babc738e11d76a4dfe3cbf68a61d6d1137f95 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 30 Mar 2026 10:59:06 +0200 Subject: [PATCH 9/9] cleanup --- .../implementation-plan.md | 1003 ----------------- src/PropertyProcessor/PropertyFactory.php | 6 +- 2 files changed, 2 insertions(+), 1007 deletions(-) delete mode 100644 .claude/topics/reworkstructure-analysis/implementation-plan.md diff --git a/.claude/topics/reworkstructure-analysis/implementation-plan.md b/.claude/topics/reworkstructure-analysis/implementation-plan.md deleted file mode 100644 index a06f636b..00000000 --- a/.claude/topics/reworkstructure-analysis/implementation-plan.md +++ /dev/null @@ -1,1003 +0,0 @@ -# Implementation Plan: Draft-Based Architecture Rework - -Based on `implementation-analysis.md` and the Q&A decisions recorded there. - ---- - -## Guiding principles - -- Every phase must leave the full test suite green before the next phase begins. -- Each phase is a standalone PR — no phase may depend on uncommitted work from another. -- The existing integration-style test suite (generate → instantiate → assert) is the primary - regression guard. Unit tests for new classes are added in the same phase that introduces them. -- "Non-breaking inside the phase" means: no public API removal until the phase that explicitly - targets that removal (the public API of the library is `ModelGenerator`, `GeneratorConfiguration`, - and the Schema/Property interfaces used by post-processors). - ---- - -## Phase 0 — `RenderJob` simplification - -**Goal**: Remove `classPath`/`className` from `RenderJob` constructor (they already live on -`Schema`). Purely internal, zero behaviour change. - -**Scope**: -- Read `RenderJob` constructor — already done: it only takes `Schema $schema`. This phase is - already complete in the current codebase. **Skip.** - ---- - -## Phase 1 — Introduce `DraftInterface`, `Draft_07`, `AutoDetectionDraft` **[DONE]** - -**Goal**: Define the Draft abstraction and wire it into `GeneratorConfiguration`. All existing -processor classes remain intact and are not yet called through the Draft. The Draft is structurally -present but the pipeline does not use it yet. - -### Implemented structure - -New files (all created and committed): -- `src/Draft/Modifier/ModifierInterface.php` — single method: - `modify(SchemaProcessor, Schema, PropertyInterface, JsonSchema): void` -- `src/Draft/Element/Type.php` — holds a type name, an optional `TypeCheckModifier` added by - default, and a list of `ModifierInterface[]`. Has `addModifier(ModifierInterface): self` and - `getModifiers(): ModifierInterface[]`. -- `src/Draft/DraftBuilder.php` — collects `Type` objects keyed by type name; - `addType(Type): self`, `getType(string): ?Type`, `build(): Draft`. -- `src/Draft/Draft.php` — value object holding `Type[]`; `getTypes(): Type[]`, - `getCoveredTypes(string|array $type): Type[]` (includes `'any'` type automatically). -- `src/Draft/DraftInterface.php` — `getDefinition(): DraftBuilder` -- `src/Draft/DraftFactoryInterface.php` — `getDraftForSchema(JsonSchema): DraftInterface` -- `src/Draft/Draft_07.php` — implements `DraftInterface`. Returns a `DraftBuilder` with all - seven JSON Schema types (`object`, `array`, `string`, `integer`, `number`, `boolean`, `null`) - plus an `'any'` pseudo-type for universal modifiers. Types currently have only - `TypeCheckModifier` (via `Type` constructor); `object` and `any` pass `false` for - `$typeCheck`. `'any'` holds `DefaultValueModifier`. -- `src/Draft/AutoDetectionDraft.php` — implements `DraftFactoryInterface`. Caches draft - instances per class. Currently always returns `Draft_07` (all schemas fall back to it). -- `src/Draft/Modifier/TypeCheckModifier.php` — implements `ModifierInterface`; adds - `TypeCheckValidator` if not already present. -- `src/Draft/Modifier/DefaultValueModifier.php` — implements `ModifierInterface`; reads - `$json['default']` and calls `$property->setDefaultValue(...)`. - -`GeneratorConfiguration` holds a `DraftFactoryInterface $draftFactory` (defaulting to -`AutoDetectionDraft`) with `getDraftFactory()` / `setDraftFactory()`. - -### Docs for Phase 1 - -- Add `docs/source/generator-configuration.rst` section describing `setDraftFactory()` and the - draft concept at a high level. - ---- - -## Phase 2 — Eliminate `PropertyMetaDataCollection` - -**Goal**: Drop `PropertyMetaDataCollection` entirely. Both pieces of data it carried -(`required` array and `dependencies` map) are already available from `Schema::getJsonSchema()` -or can be passed as a plain `bool $required` at call sites. This eliminates all save/restore -mutations of shared `Schema` state that were introduced as workarounds, including those that -the original plan deferred to Phases 7 and 8. - -### Why `PropertyMetaDataCollection` can be dropped completely - -`PropertyMetaDataCollection` carried two pieces of data: - -1. **`required` array** — always available at - `$schema->getJsonSchema()->getJson()['required']` on the parent `Schema`. For synthetic - properties (composition elements, additional/pattern/tuple properties), the required state - is a fixed call-site decision — not schema data — so it belongs as a direct `bool $required` - parameter rather than being injected via a mutable collection. - -2. **`dependencies` map** — available at - `$schema->getJsonSchema()->getJson()['dependencies']` on the parent `Schema`. Dependency - validation is only semantically correct at the point where the parent schema's JSON is - available alongside the named property being added — i.e. in - `BaseProcessor::addPropertiesToSchema`. Moving the `addDependencyValidator` call there, - reading directly from `$json['dependencies'][$propertyName]`, eliminates any need to - thread dependency data through the pipeline. - -### 2.1 — Add `bool $required = false` to `PropertyFactory::create` and processor constructors - -- `PropertyFactory::create` gains `bool $required = false` parameter. -- `ProcessorFactoryInterface::getProcessor` gains `bool $required = false` parameter. -- `PropertyProcessorFactory::getProcessor` and `getSingleTypePropertyProcessor` gain - `bool $required = false` and pass it to the processor constructor. -- `AbstractPropertyProcessor::__construct` gains `protected bool $required = false`. -- `ComposedValueProcessorFactory::getProcessor` gains `bool $required = false` and passes - it through. - -### 2.2 — Replace `isAttributeRequired` lookups - -Everywhere `isAttributeRequired($propertyName)` was called: - -- **`AbstractValueProcessor::process`** — replace with `$this->required` (constructor value). -- **`ConstProcessor::process`** — same. -- **`ReferenceProcessor::process`** — use `$this->required` when calling - `$definition->resolveReference($propertyName, $path, $this->required)`. - -For the **normal named-property path** (`BaseProcessor::addPropertiesToSchema`), compute -`$required = in_array($propertyName, $json['required'] ?? [], true)` at the call site and -pass it to `PropertyFactory::create`. - -### 2.3 — Move `addDependencyValidator` to `BaseProcessor::addPropertiesToSchema` - -- Remove the `getAttributeDependencies` call and `addDependencyValidator` invocation from - `AbstractPropertyProcessor::generateValidators` entirely. -- In `BaseProcessor::addPropertiesToSchema`, after calling `$propertyFactory->create(...)`, - check `$json['dependencies'][$propertyName] ?? null` and call - `$this->addDependencyValidator($property, $deps)` directly at that level. -- `addDependencyValidator` itself moves to `BaseProcessor` (from `AbstractPropertyProcessor`). - -### 2.4 — Update `SchemaDefinition::resolveReference` - -- Change signature: replace `PropertyMetaDataCollection $propertyMetaDataCollection` with - `bool $required` and `?array $dependencies = null`. -- Remove all save/restore of `$schema->getPropertyMetaData()`. -- Apply `$property->setRequired($required)` on the returned property. -- New cache key: `implode('-', [...$originalPath, $required ? '1' : '0', md5(json_encode($dependencies))])`. - -**Why the dependencies must be in the cache key (not the property name):** - -The original PMC hash encoded `[dependencies, required]` for the specific property — not the -property name itself. This meant two properties with different names but the same required/dependency -state (e.g. `property1` and `item of array property1` both optional with no dependencies) produced -the same cache key, which is essential for breaking recursive `$ref` cycles: the second call -(with the different property name) hits the sentinel entry for the first call and returns a proxy. - -Including `$propertyName` in the key would break recursion: each level of recursion uses a -different name (e.g. `property` → `item of array property` → `item of array item of array -property` …), so no sentinel is ever hit and the resolution loops infinitely. - -However, dependencies MUST be in the key: two properties with the same `required` state but -different dependency arrays (e.g. `property1` depends on `property3: string` while `property2` -depends on `property3: integer`) would otherwise share a cached entry and add their dependency -validators to the same underlying property object. - -**How dependencies reach `resolveReference`:** - -`BaseProcessor` injects `'_dependencies'` into the property's `JsonSchema` before calling -`PropertyFactory::create`. `ReferenceProcessor` reads `$propertySchema->getJson()['_dependencies']` -and passes it to `resolveReference`. All other call sites pass `null` (no dependencies). - -### 2.5 — Remove all PMC save/restore sites - -All five save/restore blocks introduced in Phase 2 (and noted as Phase 7/8 debt in the -original plan) are removed in this phase: - -- **`AdditionalPropertiesValidator`** — remove save/restore; pass `$required = true` to - `PropertyFactory::create` (synthetic `'additional property'` is always treated as required). -- **`PatternPropertiesValidator`** — same, always `$required = true`. -- **`ArrayTupleValidator`** — same, each tuple item is always `$required = true`. -- **`AbstractComposedValueProcessor::getCompositionProperties`** — remove save/restore; pass - `$property->isRequired()` as `bool $required` to `PropertyFactory::create`. Because - `NotProcessor` calls `$property->setRequired(true)` before `parent::generateValidators`, - this value is correctly `true` when processing `not` composition elements. -- **`IfProcessor`** — same, pass `$property->isRequired()`. - -Also: `AbstractPropertyProcessor::addComposedValueValidator` must pass `$property->isRequired()` -as `$required` to `PropertyFactory::create`. Without it, the composed value property for a -required property is constructed with `required=false`, causing `isImplicitNullAllowed` to return -`true` inside `AnyOfProcessor`/`OneOfProcessor`, which then incorrectly adds `null` to the type -hint and allows null input for required properties. - -### 2.6 — Delete `PropertyMetaDataCollection` and clean up `Schema` - -- Remove `getPropertyMetaData()` and `setPropertyMetaData()` from `Schema`. -- Remove the `$propertyMetaData` field and its initialisation from `Schema::__construct`. -- Delete `src/PropertyProcessor/PropertyMetaDataCollection.php`. - -### Tests for Phase 2 - -- `PropertyProcessorFactoryTest` — remove `PropertyMetaDataCollection` from all - `getProcessor` calls (already done in the initial Phase 2 commit); verify the test still - passes with the `bool $required` parameter added. -- The entire existing integration test suite is the regression guard. Key scenarios: - - **Issue 86 `ref.json`** — same `$ref` resolved as required vs optional; verifies the - simplified cache key (`$required` bool) correctly separates the two entries. - - **Issue 86 `schemaDependency.json`** — `$ref` properties with schema dependencies; - verifies dependency validators are correctly attached by `BaseProcessor` after - `resolveReference` returns. - - **`ComposedNotTest::ReferencedObjectSchema`** — `not` composition with a `$ref` to a - local definition; verifies that `NotProcessor`'s forced `required=true` reaches - `resolveReference` via the constructor parameter. - - **`PropertyDependencyTest`, `SchemaDependencyTest`** — all dependency validator paths. -- No new test schemas or test methods are needed; the existing tests provide complete coverage. - -### Docs for Phase 2 - -- Update any doc page that shows `PropertyFactory::create` with its parameter list. - -### Bridge-period debt resolved - -The original plan deferred the following PMC-mutation workarounds to Phases 7 and 8: - -- `AbstractComposedValueProcessor::getCompositionProperties` save/restore — **resolved here**. -- `AdditionalPropertiesValidator`, `PatternPropertiesValidator`, `ArrayTupleValidator` - save/restore — **resolved here**. -- `SchemaDefinition::resolveReference` PMC parameter — **resolved here**. - -Phase 7 and Phase 8 no longer need to address any PMC-related cleanup. - ---- - -## Phase 3 — `PropertyFactory` constructs `Property`; first modifiers land **[DONE]** - -**Goal**: Move `Property` object construction from `AbstractValueProcessor::process` into -`PropertyFactory::create`. Wire `PropertyFactory` to ask the Draft for modifiers. Introduce the -first two concrete modifier classes (`TypeCheckModifier`, `DefaultValueModifier`) and fill them -into `Draft07`. Keep all existing processor classes intact — they run _after_ the modifier -pipeline as a temporary bridge, deduplicated by the property state they observe. - -**This is the most delicate phase** because `PropertyFactory::create` currently just routes to -a processor. After this phase it both constructs `Property` and calls the processor (which -still constructs a second `Property` internally). The temporary bridge strategy: - -- `PropertyFactory::create` constructs the `Property`, sets required/readOnly, runs Draft - modifiers, then calls the legacy processor's `process()`. -- Legacy processors that call `parent::process()` (i.e. `AbstractValueProcessor`) will - construct a _second_ `Property` internally. To avoid duplicate validators, the legacy - processors are updated in Phase 4 to skip construction and receive the existing property. -- For Phase 3 only, the `AbstractTypedValueProcessor`-level TypeCheckValidator is skipped - if `TypeCheckModifier` already added it — detected by checking whether the property already - carries a `TypeCheckValidator` for that type. - -### 3.1 — Modifier: `TypeCheckModifier` - -New file `src/Draft/Modifier/TypeCheckModifier.php`: -```php -class TypeCheckModifier implements ModifierInterface { - public function __construct(private readonly string $type) {} - public function modify(...): void { - // Add TypeCheckValidator only if not already present - $property->addValidator( - new TypeCheckValidator($this->type, $property, $schemaProcessor->..isImplicitNullAllowed..($property)), - 2, - ); - } -} -``` - -Register in `Draft07`: each type gets `new TypeCheckModifier($type)` as its first modifier. -`object` type does NOT get a `TypeCheckModifier` — objects are identified by instantiation, not -a raw type check. `null` gets `new TypeCheckModifier('null')`. - -### 3.2 — Modifier: `DefaultValueModifier` - -New file `src/Draft/Modifier/DefaultValueModifier.php` — reads `$json['default']`, validates -it against the type, calls `$property->setDefaultValue(...)`. - -Register in `Draft07` after `TypeCheckModifier` for all scalar types. - -### 3.3 — `PropertyFactory::create` pipeline - -```php -public function create( - SchemaProcessor $schemaProcessor, - Schema $schema, - string $propertyName, - JsonSchema $propertySchema, -): PropertyInterface { - $json = $propertySchema->getJson(); - - // Resolve draft from schema's $schema keyword - $draft = $schemaProcessor->getGeneratorConfiguration()->getDraft(); - if ($draft instanceof AutoDetectionDraft) { - $draft = $draft->getDraftForSchema($propertySchema); - } - - // Construct Property (was: inside AbstractValueProcessor) - $property = new Property($propertyName, null, $propertySchema, $json['description'] ?? ''); - $property - ->setRequired($schema->getPropertyMetaData()->isAttributeRequired($propertyName)) - ->setReadOnly( - (isset($json['readOnly']) && $json['readOnly'] === true) - || $schemaProcessor->getGeneratorConfiguration()->isImmutable() - ); - - // Resolve types and run type-specific modifiers - $types = $this->resolveTypes($json); - foreach ($types as $type) { - foreach ($draft->getModifiersForType($type) as $modifier) { - $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); - } - } - - // Universal modifiers - foreach ($draft->getUniversalModifiers() as $modifier) { - $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); - } - - // Legacy bridge: route to existing processor (temporary, removed in later phases) - $property = $this->legacyProcess($json, $propertyMetaData, $schemaProcessor, $schema, $propertyName, $propertySchema, $property); - - return $property; -} -``` - -`resolveTypes`: returns `['string']` for `"type":"string"`, `['string','null']` for -`"type":["string","null"]`, `[]` for no type (untyped / `any`). - -The `legacyProcess` bridge calls the old `ProcessorFactoryInterface` path. It is removed -phase-by-phase from Phase 4 onward. - -### 3.4 — Prevent duplicate TypeCheckValidator in bridge period - -`AbstractTypedValueProcessor::generateValidators` currently always adds `TypeCheckValidator`. -Add a guard: check if the property already has a `TypeCheckValidator` for `static::TYPE` -before adding another. This is the minimal change needed to bridge Phase 3 without duplicates. - -### Tests for Phase 3 - -- New unit tests for `TypeCheckModifier` and `DefaultValueModifier` in `tests/Draft/Modifier/`. -- `tests/Objects/MultiTypePropertyTest.php` — must stay green (bridge still handles multi-type - via `MultiTypeProcessor`). -- Full integration suite must stay green. - -### Implementation notes (Phase 3) - -Implemented using Option B (legacy processor first, then modifiers on returned property). - -Key fixes required during implementation: -- `AbstractComposedValueProcessor::getCompositionProperties`: temporarily overrides the schema - PMC to reflect the parent property's `isRequired` state for each composition element. This is - essential for `NotProcessor` (which sets `isRequired=true` on the composition property to enforce - strict null checks), so that sub-properties (including referenced schemas) see the correct state. -- `AdditionalPropertiesValidator`, `PatternPropertiesValidator`, `ArrayTupleValidator`: similarly - override the schema PMC to mark validation sub-properties as required, restoring the behaviour - that Phase 1 provided via explicit PMC construction. - ---- - -## Phase 4 — Migrate scalar type keyword validators to validator factories **[DONE — commit 3bec251]** - -**Goal**: For each scalar type (`string`, `integer`, `number`, `boolean`, `null`, `array`), -move all keyword-specific validator generation from the processor `generateValidators` method -into dedicated classes. Register them in `Draft07`. Once all keywords for a type are migrated, -that processor's `generateValidators` override is deleted. - -Ordering within a type's modifier list mirrors the current `generateValidators` call order. - -### Class hierarchy for keyword-driven validators - -Most JSON Schema keywords follow a simple pattern: check whether the keyword is present in the -property's JSON, validate its value, then add a validator to the property. This phase introduces -a reusable class hierarchy for this pattern: - -- **`AbstractValidatorFactory`** (`src/Model/Validator/Factory/AbstractValidatorFactory.php`) — - implements `ModifierInterface` (already defined in Phase 1). Holds `protected $key` injected - via `setKey(string $key): void`. The `$key` is the JSON Schema keyword name (e.g. `'minLength'`), - set by `Type::addValidator` at registration time (see below). -- **`SimplePropertyValidatorFactory`** — extends `AbstractValidatorFactory`. Provides `modify()`: - reads `$property->getJsonSchema()->getJson()[$this->key]`, calls `hasValidValue()` (which also - throws `SchemaException` for invalid values), then calls abstract `getValidator()` and adds the - validator to the property. Subclasses only implement `isValueValid($value): bool` and - `getValidator(PropertyInterface, $value): PropertyValidatorInterface`. -- **`SimpleBaseValidatorFactory`** — same pattern but calls `$schema->addBaseValidator()` instead - of `$property->addValidator()`, for root-schema validators. - -**How the JSON keyword is bound to the factory**: `Type::addValidator(string $validatorKey, -AbstractValidatorFactory $factory)` is a new method added to `Type` in this phase. It calls -`$factory->setKey($validatorKey)` before appending the factory to the modifier list. This means -factories do **not** hard-code their keyword — they receive it from the registration call. The -same factory class can be reused for different keywords if the logic is identical. - -`TypeCheckModifier` also adds a validator (a `TypeCheckValidator`), but it is not keyed to a -single JSON keyword — the type comes from the `Type` registration itself. It could equally be -named `TypeCheckValidatorFactory`, but because it implements `ModifierInterface` directly without -the `$key`/`setKey` mechanism, the `*Modifier` suffix is used to signal that it is not part of the -`AbstractValidatorFactory` hierarchy. The same applies to `DefaultValueModifier` (which modifies a -property attribute rather than adding a validator). The naming rule is therefore: `*ValidatorFactory` -for classes that extend `AbstractValidatorFactory` (keyed via `setKey`); `*Modifier` for classes -that implement `ModifierInterface` directly (non-keyed). - -### Classes to create - -**String** (namespace `src/Model/Validator/Factory/String/`): -- `PatternPropertyValidatorFactory` — extends `SimplePropertyValidatorFactory`; from `StringProcessor::addPatternValidator` -- `MinLengthPropertyValidatorFactory` — extends `SimplePropertyValidatorFactory`; from `StringProcessor::addLengthValidator` (min part) -- `MaxLengthValidatorFactory` — extends `MinLengthPropertyValidatorFactory`; from `StringProcessor::addLengthValidator` (max part) -- `FormatValidatorFactory` — extends `AbstractValidatorFactory`; from `StringProcessor::addFormatValidator` - -**Integer / Number** (namespace `src/Model/Validator/Factory/Number/`): -- `MinimumValidatorFactory` — extends `SimplePropertyValidatorFactory`; from `AbstractNumericProcessor::addRangeValidator` (minimum) -- `MaximumValidatorFactory` -- `ExclusiveMinimumValidatorFactory` -- `ExclusiveMaximumValidatorFactory` -- `MultipleOfPropertyValidatorFactory` — extends `SimplePropertyValidatorFactory` - -**Array** (namespace `src/Model/Validator/Factory/Arrays/`): -- `MinItemsValidatorFactory`, `MaxItemsValidatorFactory`, `UniqueItemsValidatorFactory` — extend `SimplePropertyValidatorFactory` -- `ItemsValidatorFactory` — extends `AbstractValidatorFactory`; handles `items`, `additionalItems`, tuples -- `ContainsValidatorFactory` — extends `SimplePropertyValidatorFactory` - -**Object** (namespace `src/Model/Validator/Factory/Object/`): -- `PropertiesValidatorFactory` — extends `AbstractValidatorFactory`; from `BaseProcessor::addPropertiesToSchema` -- `PropertyNamesValidatorFactory` — extends `AbstractValidatorFactory` -- `PatternPropertiesValidatorFactory` — extends `AbstractValidatorFactory` -- `AdditionalPropertiesValidatorFactory` — extends `AbstractValidatorFactory` -- `MinPropertiesValidatorFactory`, `MaxPropertiesValidatorFactory` — extend `SimplePropertyValidatorFactory` - -**Universal** (namespace `src/Model/Validator/Factory/Any/`): -- `EnumValidatorFactory` — extends `AbstractValidatorFactory`; from `AbstractPropertyProcessor::addEnumValidator` -- `FilterValidatorFactory` — extends `AbstractValidatorFactory`; from `AbstractValueProcessor` filter call - -**Deferred** (not part of Phase 4): -- `RequiredValidatorFactory` — deferred to Phase 6/7; `RequiredPropertyValidator` is added inside - `AbstractPropertyProcessor::generateValidators` which is tightly coupled to the full processor - hierarchy, and required-state handling interacts with composition. -- `DependencyValidatorFactory` — deferred to Phase 6; dependency validation lives in - `BaseProcessor::addPropertiesToSchema` and requires `$this->schemaProcessor` + `$this->schema` - for schema-dependency processing — it belongs with the object keyword migration. -- `ReferenceValidatorFactory` — deferred to Phase 7 (`$ref` handling is tightly coupled to composition routing) -- `AllOfValidatorFactory` / other composition factories — deferred to Phase 7 (composition is the most complex migration) - -**Note on naming**: the `*ValidatorFactory` suffix is used for all classes that follow the -`AbstractValidatorFactory` hierarchy. The `*Modifier` suffix (e.g. `TypeCheckModifier`, -`DefaultValueModifier`) is reserved for objects that implement `ModifierInterface` directly and -perform property modification beyond a simple key→validator mapping. - -### Registration in `Draft_07` - -`Draft_07` registers factories on `Type` objects via `addValidator(string $key, AbstractValidatorFactory)`. -Example: - -```php -(new Type('string')) - ->addValidator('pattern', new PatternPropertyValidatorFactory()) - ->addValidator('minLength', new MinLengthPropertyValidatorFactory()) - ->addValidator('maxLength', new MaxLengthValidatorFactory()) - ->addValidator('format', new FormatValidatorFactory()), -``` - -The `Type::addValidator` method calls `$factory->setKey($key)`, injecting the JSON keyword name -into the factory before it is stored. This is the only place the keyword name is bound — factory -classes never hard-code it. - -### Migration strategy per type - -For each type, the work is: -1. Create the `AbstractValidatorFactory` subclass(es). The `$key` property is set by the - `Type` registry at registration time, not in the constructor. -2. Register in `Draft_07` via `Type::addValidator`. -3. Add deduplication guard in the legacy processor (same pattern as Phase 3: skip if - validator already present). -4. Delete the `generateValidators` override in the processor once all keywords are covered. -5. If the processor class becomes empty (only inherits from `AbstractTypedValueProcessor`), - mark it `@deprecated` — deletion happens in Phase 8. - -### Tests for Phase 4 - -- New unit tests for each validator factory class. -- All existing integration tests (`StringPropertyTest`, `IntegerPropertyTest`, - `NumberPropertyTest`, `ArrayPropertyTest`, etc.) must stay green after each sub-step. -- Run the full suite after each type is completed, not just at the end of the phase. - -### Docs for Phase 4 - -No user-visible behaviour change. No doc updates needed this phase. - ---- - -## Phase 5 — Eliminate `MultiTypeProcessor` **[DONE — commit 997b639]** - -**Goal**: `"type": ["string","null"]` is handled by `PropertyFactory` directly — it iterates -the type list, processes each type through the legacy per-type processor to collect validators -and decorators, merges them onto a single `Property`, consolidates `TypeCheckValidator` -instances into a `MultiTypeCheckValidator`, and runs universal modifiers once. -`MultiTypeProcessor` is deleted. - -### Why this approach - -After Phase 4, every keyword validator factory reads from the property's `JsonSchema` directly. -Each factory is keyed to a distinct JSON keyword (`minLength`, `minimum`, `minItems`, …), so -running multiple types' modifier lists against the same schema produces no duplicates — each -keyword fires at most once, for whichever type owns it. The `$checks` deduplication array inside -`MultiTypeProcessor` guarded against a scenario that no longer occurs. - -The sub-property architecture existed only to isolate per-type keyword validators from each -other. With the modifier system that isolation is implicit. We can therefore process each type -directly and merge the results, which eliminates `onResolve` deferral, `processSubProperties`, -`transferValidators`, and the `$checks` accumulator. - -### 5.1 — Inline multi-type handling in `PropertyFactory::create` - -When `$resolvedType` is an array, `PropertyFactory::create` takes the new path instead of -delegating to `MultiTypeProcessor`: - -1. **Construct the main property** directly (same as `AbstractValueProcessor::process` does): - `new Property($propertyName, null, $propertySchema, $json['description'] ?? '')` with - `setRequired`/`setReadOnly` applied. - -2. **Iterate types**: for each type in the array, call the legacy processor's `process()` to - obtain a sub-property (same flow as before, reusing `getSingleTypePropertyProcessor`), then: - - Collect all `TypeCheckInterface` validators from the sub-property into a `$collectedTypes` - string array (via `getTypes()`). - - Transfer all non-`TypeCheckInterface` validators onto the main property (preserving priority). - - If the sub-property has decorators, attach a `PropertyTransferDecorator` to the main - property (covers the `object` sub-type case where `ObjectInstantiationDecorator` lives - on the sub-property). - - Collect the sub-property's type hint via `getTypeHint()` into `$typeHints`. - -3. **Consolidate type check**: after the loop, if `$collectedTypes` is non-empty: - - Add one `MultiTypeCheckValidator(array_unique($collectedTypes), $property, isImplicitNullAllowed)`. - - Set the union `PropertyType`: separate `$collectedTypes` into non-null types and a - `$hasNull` flag; call `$property->setType(new PropertyType($nonNullTypes, $hasNull ?: null), ...)`. - - Add a `TypeHintDecorator` built from the `$typeHints` collected per sub-property. - -4. **Run universal modifiers** once on the main property (handles `default`, `enum`, `filter`). - `DefaultValueModifier` already handles the multi-type case — it iterates `$json['type']` - (which is the full array) and accepts the default if any type matches. - -`isImplicitNullAllowed` is determined the same way as in `AbstractPropertyProcessor`: -`$schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() && !$property->isRequired()`. - -### 5.2 — Make `applyTypeModifiers` and `applyUniversalModifiers` private - -These methods were `public` only because `MultiTypeProcessor` called them from outside -`PropertyFactory`. After this phase they have no external callers; make them `private`. - -### 5.3 — Delete `MultiTypeProcessor` and array-type branch - -Delete: -- `src/PropertyProcessor/Property/MultiTypeProcessor.php` -- The `is_array($type)` branch in `PropertyProcessorFactory::getProcessor` - -### Tests for Phase 5 **[DONE]** - -- `tests/Objects/MultiTypePropertyTest.php` — all existing cases pass. -- Three `invalidRecursiveMultiTypeDataProvider` expectations updated: the outer `InvalidItemException` - property name changes from `"item of array property"` to `"property"`. This is a cosmetic difference - in error message wording caused by the change in which `ArrayItemValidator` instance registers the - extracted method first. Both messages are semantically valid. The inner error messages are unchanged. -- Full suite green (2254 tests, 1 pre-existing deprecation). - ---- - -## Phase 6 — `ObjectProcessor` as modifier; `object` modifier list complete - -**Goal**: The `'object'` type modifier list in `Draft07` is completed with all object keyword -modifiers (originally scoped to Phase 4 but deferred — see note below) plus a new `ObjectModifier` -that handles nested-object instantiation (what `ObjectProcessor::process` does today). The -`BaseProcessor` keyword methods are also migrated to modifiers so the `type=base` path runs -entirely through `PropertyFactory` → Draft modifiers. `ObjectProcessor` and `BaseProcessor` are -then deprecated and the legacy bridge for `type=object`/`type=base` is removed. - -**Note on Phase 4 omission**: The object keyword validator factories were listed in Phase 4's scope -but were not implemented in that phase. They are implemented here in Phase 6 alongside the -`ObjectModifier`. Unlike scalar keyword factories (which extend `AbstractValidatorFactory`), the -object keyword handlers are too complex for `SimplePropertyValidatorFactory` and are implemented -as `ModifierInterface` classes directly (following the `*Modifier` naming convention). - -**Architectural decision — single `object` type entry**: The Draft does not differentiate between -`type=base` (root class) and `type=object` (nested property). A single `'object'` entry in -`Draft_07` holds ALL object modifiers — keyword modifiers and `ObjectModifier` — because the same -JSON Schema keywords (`properties`, `minProperties`, `additionalProperties`, etc.) apply to every -object-typed schema regardless of context. This also preserves the ability for users to attach -custom modifiers to the `object` type cleanly. - -The key to making this work: keyword modifiers always add validators/properties to `$schema`. -For `type=base`, `$schema` is the class being built — correct. For `type=object` nested -properties, `PropertyFactory` resolves the nested Schema first (via `processSchema`), then passes -that nested Schema as `$schema` when invoking the Draft modifiers. `ObjectModifier` then wires -the instantiation linkage (`ObjectInstantiationDecorator`, `InstanceOfValidator`, `setType`, -`setNestedSchema`) on the outer property. Result: same modifier list, always correct `$schema`. - -### 6.1 — Object keyword modifiers - -New files in `src/Draft/Modifier/ObjectType/`: - -- **`PropertiesModifier`** — extracts `BaseProcessor::addPropertiesToSchema`. Reads - `$json['properties']` (and fills in entries for undeclared-but-required properties), then for - each property calls `PropertyFactory::create` and `$schema->addProperty`. Also calls - `addDependencyValidator` for properties that have a dependency. The `addDependencyValidator` - helper is lifted out of `BaseProcessor` into a private method on `PropertiesModifier`. -- **`PropertyNamesModifier`** — extracts `BaseProcessor::addPropertyNamesValidator`. Reads - `$json['propertyNames']` and adds a `PropertyNamesValidator` to `$schema`. -- **`PatternPropertiesModifier`** — extracts `BaseProcessor::addPatternPropertiesValidator`. - Reads `$json['patternProperties']` and adds `PatternPropertiesValidator` instances to `$schema`. -- **`AdditionalPropertiesModifier`** — extracts `BaseProcessor::addAdditionalPropertiesValidator`. - Reads `$json['additionalProperties']` (and the generator config's `denyAdditionalProperties`) - and adds `AdditionalPropertiesValidator` or `NoAdditionalPropertiesValidator` to `$schema`. -- **`MinPropertiesModifier`** / **`MaxPropertiesModifier`** — extract - `BaseProcessor::addMinPropertiesValidator` / `addMaxPropertiesValidator`. Each adds a - `PropertyValidator` to `$schema`'s base validators. - -The dependency-related helpers (`addDependencyValidator`, `transferDependentPropertiesToBaseSchema`) -that currently live in `BaseProcessor` move into `PropertiesModifier` as private methods. - -All modifiers call `$schema->addBaseValidator(...)` / `$schema->addProperty(...)`, exactly as -`BaseProcessor` does today. - -### 6.2 — `ObjectModifier` - -New file `src/Draft/Modifier/ObjectType/ObjectModifier.php` — wires the instantiation linkage -for `type=object` properties: -- Adds `ObjectInstantiationDecorator`, `InstanceOfValidator`, sets `PropertyType` to class name, - sets `nestedSchema` on the property -- Handles namespace transfer (adds `usedClass` and `SchemaNamespaceTransferDecorator` when - the nested schema lives in a different namespace) -- **Does NOT call `processSchema`** — the nested Schema is already available on the property - via `$property->getNestedSchema()`, set before the modifier list runs (see 6.4) -- Skips when `$property instanceof BaseProperty` (root schema — no instantiation needed) - -### 6.3 — Register in `Draft_07` - -Register all object modifiers on the `object` type in `Draft_07::getDefinition()`: - -```php -(new Type('object', false)) - ->addModifier(new PropertiesModifier()) - ->addModifier(new PropertyNamesModifier()) - ->addModifier(new PatternPropertiesModifier()) - ->addModifier(new AdditionalPropertiesModifier()) - ->addModifier(new MinPropertiesModifier()) - ->addModifier(new MaxPropertiesModifier()) - ->addModifier(new ObjectModifier()) -``` - -(`addModifier` is the appropriate call since these are `ModifierInterface` implementations -directly, not `AbstractValidatorFactory` subclasses.) - -### 6.4 — `PropertyFactory` handling of `type=object` and `type=base` - -The trick that makes the single `object` modifier list work for both contexts: `PropertyFactory` -**resolves the nested Schema before running modifiers**, then passes that nested Schema as -`$schema` when invoking `applyDraftModifiers`. Keyword modifiers always add to `$schema` — -which is now the correct target in both cases. - -**For `type=base` (root schema):** -``` -SchemaProcessor::generateModel - → PropertyFactory::create(type=base) - → construct BaseProperty - → applyDraftModifiers(schemaProcessor, schema=$outerSchema, property, propertySchema) - → PropertiesModifier — adds to $outerSchema ✓ (it IS the class being built) - → PropertyNamesModifier — adds to $outerSchema ✓ - → … other keyword modifiers … - → ObjectModifier — skips (instanceof BaseProperty) -``` - -**For `type=object` (nested property):** -``` -PropertyFactory::create(type=object) - → construct Property - → call processSchema → returns nestedSchema - → store nestedSchema on property (property->setNestedSchema(nestedSchema)) - → applyDraftModifiers(schemaProcessor, schema=$nestedSchema, property, propertySchema) - → PropertiesModifier — adds to $nestedSchema ✓ - → PropertyNamesModifier — adds to $nestedSchema ✓ - → … other keyword modifiers … - → ObjectModifier — wires ObjectInstantiationDecorator, InstanceOfValidator, setType - on the outer property using the already-set nestedSchema ✓ -``` - -`processSchema` is called from `PropertyFactory` before `applyDraftModifiers`. This replaces -what `ObjectProcessor::process` previously did. `ObjectModifier` no longer calls `processSchema` -at all — it only reads `$property->getNestedSchema()`. - -**For `type=base`**, `applyDraftModifiers` is called with the existing outer `$schema` (unchanged -from before). `PropertyFactory` does NOT call `processSchema` for `type=base` — `generateModel` -already created the Schema before calling `PropertyFactory::create`. - -The `type=base` path in `PropertyFactory` still routes through the legacy `BaseProcessor` for -the bridge period (composition/required validators). The object keyword modifiers run via -`applyDraftModifiers` AFTER the legacy processor, with dedup guards in the legacy processor's -keyword methods to skip if the modifier already added the validator. - -**Simpler chosen approach**: remove the legacy bridge for `type=object` immediately (clean cut, -no dedup needed). For `type=base`, keep `BaseProcessor` as the bridge but bypass its keyword -methods since `applyDraftModifiers` now handles them. `BaseProcessor::process` is updated to -skip `addPropertiesToSchema`, `addPropertyNamesValidator`, `addPatternPropertiesValidator`, -`addAdditionalPropertiesValidator`, `addMinPropertiesValidator`, `addMaxPropertiesValidator` — -these are now handled by Draft modifiers. It retains only `setUpDefinitionDictionary`, -`generateValidators` (composition), and `transferComposedPropertiesToSchema`. - -### 6.5 — Deprecate `ObjectProcessor` and `BaseProcessor` - -Once the modifiers are in place and the legacy bridges are updated: -- Mark `ObjectProcessor` as `@deprecated` (deletion in Phase 8) -- Mark `BaseProcessor` as `@deprecated` (deletion in Phase 8) -- `PropertyProcessorFactory` no longer routes `object` to a legacy processor -- `base` still routes to the stripped-down `BaseProcessor` bridge (composition only) - -### Tests for Phase 6 - -- All object-property and nested-object integration tests must stay green. -- `ObjectPropertyTest`, `IdenticalNestedSchemaTest`, `ReferencePropertyTest`. -- All property-level tests for `properties`, `propertyNames`, `patternProperties`, - `additionalProperties`, `minProperties`, `maxProperties` must stay green. -- `BaseProcessor`-related tests (object schema tests) must stay green. - ---- - -## Phase 7 — Composition validator factories; eliminate `ComposedValueProcessorFactory` **[DONE]** - -**Goal**: Replace `AbstractComposedValueProcessor` and `ComposedValueProcessorFactory` with a -single `CompositionModifier` universal modifier. This is the highest-risk phase. - -### Bridge-period debt status - -All PMC-mutation workarounds were **fully resolved in Phase 2**: - -- `AbstractComposedValueProcessor::getCompositionProperties` save/restore → replaced by - passing `$property->isRequired()` as `bool $required` to `PropertyFactory::create`. -- `AdditionalPropertiesValidator`, `PatternPropertiesValidator`, `ArrayTupleValidator` - save/restore → replaced by passing `$required = true` to `PropertyFactory::create`. -- `SchemaDefinition::resolveReference` PMC parameter → replaced by `bool $required`. -- `PropertyMetaDataCollection` class deleted entirely. - -`NotProcessor::setRequired(true)` remains as the mechanism to force strict null-checks in -`not` composition elements. This is semantically correct: the property IS required from the -`not` branch's perspective (it must be non-null to be checked against the negated schema). -It propagates correctly via `$property->isRequired()` in `getCompositionProperties`. No -change is needed here in Phase 7. - -### 7.1 — Composition validator factories - -New files in `src/Model/Validator/Factory/Composition/`: -- `AbstractCompositionValidatorFactory` — extends `AbstractValidatorFactory`; shared helpers - (`warnIfEmpty`, `shouldSkip`, `getCompositionProperties`, `inheritPropertyType`, - `transferPropertyType`). The `$this->key` (set by `Type::addValidator`) is the composition - keyword (`allOf`, `anyOf`, etc.). -- `ComposedPropertiesValidatorFactoryInterface` — marker interface for factories that transfer - properties to the parent schema (all except `NotValidatorFactory`). -- `AllOfValidatorFactory`, `AnyOfValidatorFactory`, `OneOfValidatorFactory`, - `NotValidatorFactory`, `IfValidatorFactory` — each implements `modify()` directly. - -All registered on the `'any'` type in `Draft_07` via `addValidator('allOf', ...)` etc. -`ComposedPropertyValidator` and `ConditionalPropertyValidator` store `static::class` of the -factory as the composition processor string; all `is_a()` checks updated accordingly. - -### 7.2 — `ComposedValueProcessorFactory` deletion - -`ComposedValueProcessorFactory` and the composition processor classes were already deleted in -the prior commit (git status showed them as `D`). The validator factories are the sole -composition mechanism. - -### 7.3 — `ConstProcessor` and `ReferenceProcessor` - -These are special non-Draft-driven processors (they are not JSON Schema types but routing -signals injected by `PropertyFactory`). They remain as-is, invoked by the legacy bridge for -`type=const` and `type=reference`/`type=baseReference` special cases. These are retained as -permanent special-case routes inside `PropertyFactory` — they represent keyword-level routing -(the `$ref` and `const` keywords), not type-level routing, so they do not belong in the Draft. - -### Tests for Phase 7 - -- All `ComposedValue/` test classes must stay green. -- `ComposedAnyOfTest`, `ComposedAllOfTest`, `ComposedOneOfTest`, `ComposedNotTest`, - `ComposedIfTest`, `CrossTypedCompositionTest`, `ComposedRequiredPromotionTest`. -- All issue regression tests that involve composition: - `Issue98Test`, `Issue101Test`, `Issue105Test`, `Issue113Test`, `Issue114Test`, - `Issue116Test`, `Issue117Test`. -- Verify that the PMC-mutation workarounds from Phase 3 are **gone** — confirmed by the absence - of `setPropertyMetaData` calls outside of `SchemaDefinition::resolveReference` and - `Schema`'s own initialisation code. - ---- - -## Phase 8 — Delete empty processor classes and legacy bridge **[DONE]** - -**Goal**: Remove all now-empty or deprecated processor classes, the legacy bridge in -`PropertyFactory`, `PropertyProcessorFactory`, and -`AbstractPropertyProcessor`/`AbstractValueProcessor`/`AbstractTypedValueProcessor`. -Introduce `ConstModifier` and `NumberModifier`. Inline `$ref`/`baseReference` logic as -private methods on `PropertyFactory`. Remove the `ProcessorFactoryInterface` constructor -parameter from `PropertyFactory` entirely. - -### Remaining bridge-period debt to clean up - -At the time Phase 8 runs, the following bridge artifact must be removed: - -- The dedup guard in `AbstractTypedValueProcessor::generateValidators` (checking for existing - `TypeCheckInterface` validators) is only needed during the bridge period; it is deleted when - `AbstractTypedValueProcessor` itself is deleted. - -All PMC-mutation workarounds were already resolved in Phase 2. - -### 8.1 — `ConstModifier` on the `'any'` type - -New file `src/Draft/Modifier/ConstModifier.php` — registered on the `'any'` type in -`Draft_07` via `addModifier`. Does not use the `AbstractValidatorFactory`/`setKey` mechanism -because it needs to set both a `PropertyType` AND add a `PropertyValidator` on the same -property (two things, not just a validator keyed to one keyword). Implements -`ModifierInterface` directly. - -`ConstModifier::modify`: -- Guard: return immediately if `!isset($json['const'])`. -- Set `PropertyType` using `TypeConverter::gettypeToInternal(gettype($json['const']))`. -- Build the validator check expression (same logic as `ConstProcessor::process`): - - `$property->isRequired()` → `'$value !== ' . var_export($json['const'], true)` - - implicit null allowed → `'!in_array($value, [const, null], true)'` - - otherwise → `"array_key_exists('name', \$modelData) && \$value !== const"` -- Add `new PropertyValidator($property, $check, InvalidConstException::class, [$json['const']])`. - -The `$json['type'] = 'const'` routing signal in `PropertyFactory::create` is removed. -`ConstProcessor` is deleted. - -### 8.2 — `NumberModifier` on the `'number'` type - -New file `src/Draft/Modifier/NumberModifier.php` — registered on the `'number'` type in -`Draft_07` via `addModifier`. Adds `IntToFloatCastDecorator` unconditionally (all `number` -properties need the int→float cast). Implements `ModifierInterface` directly. - -`NumberProcessor::process` currently calls `parent::process(...)->addDecorator(new IntToFloatCastDecorator())`. -This moves into `NumberModifier` so `NumberProcessor` can be deleted. - -### 8.3 — Inline `$ref` / `baseReference` as private methods on `PropertyFactory` - -`$ref` is categorically different from keyword validators: `ReferenceProcessor::process` -**replaces** the property entirely (returns a resolved `PropertyProxy` from the definition -dictionary). The `ModifierInterface::modify` contract returns `void` and cannot replace a -property — any attempt to "copy fields" would lose the `PropertyProxy`'s deferred-resolution -callbacks. Therefore `$ref` remains a routing signal handled before Draft modifiers run. - -New private methods on `PropertyFactory`: -- `processReference(SchemaProcessor, Schema, string $propertyName, JsonSchema, bool $required): PropertyInterface` - — inlines `ReferenceProcessor::process` logic -- `processBaseReference(SchemaProcessor, Schema, string $propertyName, JsonSchema, bool $required): PropertyInterface` - — inlines `BasereferenceProcessor::process` logic (calls `processReference`, validates nested - schema, copies properties to parent schema) - -The `$json['$ref'] → type = 'reference'/'baseReference'` detection in `PropertyFactory::create` -remains but routes to these private methods instead of via `processorFactory`. - -`ReferenceProcessor` and `BasereferenceProcessor` are deleted. - -### 8.4 — Inline `base` path; construct `Property`/`BaseProperty` directly in `PropertyFactory` - -The remaining legacy bridge in `PropertyFactory::create` calls `$this->processorFactory->getProcessor(type)->process()` for: -- `base` — `BaseProcessor::process` does `setUpDefinitionDictionary` + constructs `BaseProperty` -- scalar/array/any types — `AbstractTypedValueProcessor::process` constructs `Property`, - sets required/readOnly, calls `generateValidators` (adds `RequiredPropertyValidator` + - `TypeCheckValidator` with dedup guard) - -After Phase 8 these are inlined directly: - -**`base` path**: -```php -$schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema); -$property = new BaseProperty($propertyName, new PropertyType('object'), $propertySchema); -// applyDraftModifiers and transferComposedPropertiesToSchema follow (already in place) -``` - -**Scalar/array/any paths** (`string`, `integer`, `number`, `boolean`, `null`, `array`, `any`): -```php -$property = (new Property($propertyName, null, $propertySchema, $json['description'] ?? '')) - ->setRequired($required) - ->setReadOnly(...); -if ($required && !str_starts_with($propertyName, 'item of array ')) { - $property->addValidator(new RequiredPropertyValidator($property), 1); -} -// applyDraftModifiers follows (TypeCheckValidator, keyword validators all come from Draft) -``` - -`NullProcessor` added `setType(null)` and `TypeHintDecorator(['null'])` — these must be added -as a `NullModifier` on the `'null'` type in `Draft_07`. The TypeCheckModifier already adds the -`TypeCheckValidator('null', ...)` — but `NullProcessor` called `setType(null)` to clear the -type hint. A `NullModifier` runs after `TypeCheckModifier` and calls `$property->setType(null)` -and `$property->addTypeHintDecorator(new TypeHintDecorator(['null']))`. This preserves existing -behaviour: null-typed properties have no PHP type hint (rendered as `mixed`/no type). - -### 8.5 — Remove `ProcessorFactoryInterface` from `PropertyFactory` - -`PropertyFactory::__construct` loses the `ProcessorFactoryInterface $processorFactory` parameter. -All `new PropertyFactory(new PropertyProcessorFactory())` call sites across the codebase become -`new PropertyFactory()`. - -Call sites: -- `src/SchemaProcessor/SchemaProcessor.php` (1 site) -- `src/Model/SchemaDefinition/SchemaDefinition.php` (1 site) -- `src/Model/Validator/AdditionalPropertiesValidator.php` (1 site) -- `src/Model/Validator/ArrayItemValidator.php` (1 site) -- `src/Model/Validator/ArrayTupleValidator.php` (1 site) -- `src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php` (1 site) -- `src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php` (1 site) -- `src/Model/Validator/Factory/Composition/IfValidatorFactory.php` (1 site) -- `src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php` (1 site) -- `src/Model/Validator/PatternPropertiesValidator.php` (1 site) -- `src/Model/Validator/PropertyNamesValidator.php` (1 site) -- `src/PropertyProcessor/Property/BaseProcessor.php` (1 site, deleted in 8.6) - -### 8.6 — Delete all processor classes and infrastructure - -Files to delete: -- `src/PropertyProcessor/Property/AbstractPropertyProcessor.php` -- `src/PropertyProcessor/Property/AbstractValueProcessor.php` -- `src/PropertyProcessor/Property/AbstractTypedValueProcessor.php` -- `src/PropertyProcessor/Property/AbstractNumericProcessor.php` -- `src/PropertyProcessor/Property/StringProcessor.php` -- `src/PropertyProcessor/Property/IntegerProcessor.php` -- `src/PropertyProcessor/Property/NumberProcessor.php` -- `src/PropertyProcessor/Property/BooleanProcessor.php` -- `src/PropertyProcessor/Property/NullProcessor.php` -- `src/PropertyProcessor/Property/ArrayProcessor.php` -- `src/PropertyProcessor/Property/ObjectProcessor.php` -- `src/PropertyProcessor/Property/AnyProcessor.php` -- `src/PropertyProcessor/Property/ConstProcessor.php` (replaced by ConstModifier in 8.1) -- `src/PropertyProcessor/Property/ReferenceProcessor.php` (inlined in 8.3) -- `src/PropertyProcessor/Property/BasereferenceProcessor.php` (inlined in 8.3) -- `src/PropertyProcessor/Property/BaseProcessor.php` (inlined in 8.4) -- `src/PropertyProcessor/PropertyProcessorFactory.php` -- `src/PropertyProcessor/ProcessorFactoryInterface.php` -- `src/PropertyProcessor/PropertyProcessorInterface.php` - -The `src/PropertyProcessor/Property/` directory becomes empty and is removed. - -### 8.7 — Delete `BaseProcessor::transferComposedPropertiesToSchema` - -`BaseProcessor` contains a `transferComposedPropertiesToSchema` method that duplicates the -identical method on `SchemaProcessor`. `PropertyFactory`'s `base` path already calls -`$schemaProcessor->transferComposedPropertiesToSchema`. The `BaseProcessor` copy is dead code -and is deleted along with the class. - -### Tests for Phase 8 - -- Delete `tests/PropertyProcessor/PropertyProcessorFactoryTest.php`. -- Add `tests/Draft/DraftRegistryTest.php` — verifies `Draft_07::getDefinition()->build()`: - - Returns the correct modifier list (including new modifiers) for each type - - `'null'` type has `NullModifier` in its list - - `'number'` type has `NumberModifier` in its list - - `'any'` type has `ConstModifier` in its list - - All types have `TypeCheckModifier` (except `'object'` and `'any'`) -- Add `tests/Draft/Modifier/ConstModifierTest.php` — unit tests for `ConstModifier` -- Add `tests/Draft/Modifier/NumberModifierTest.php` — unit tests for `NumberModifier` -- Add `tests/Draft/Modifier/NullModifierTest.php` — unit tests for `NullModifier` -- Full integration suite must stay green. - -### Docs for Phase 8 - -- Remove all references to `PropertyProcessorFactory` from docs. -- Update architecture overview in `CLAUDE.md`. -- Document the final `DraftInterface` / modifier system for users. - ---- - -## Cross-cutting: `AutoDetectionDraft` completion - -This runs in parallel with Phases 3–8 as each draft keyword is modelled. - -### Detection logic - -Inspect `$schema` keyword: -- `http://json-schema.org/draft-07/schema#` → `Draft07` -- `http://json-schema.org/draft-04/schema#` → `Draft04` (once implemented) -- `https://json-schema.org/draft/2020-12/schema` → `Draft202012` (once implemented) -- absent / unrecognised → `Draft07` (safe default, matches current behaviour) - -The detection runs per-`JsonSchema` (per file/component), not per generator run, so schemas -with different `$schema` declarations in the same generation run can use different drafts. - -### Where detection is called - -`PropertyFactory::create` already receives `$propertySchema: JsonSchema`. It calls -`$config->getDraft()` and, if the result is `AutoDetectionDraft`, calls -`getDraftForSchema($propertySchema)` to get the concrete draft. This is the single -detection point — no other caller needs to know about drafts. - ---- - -## Test suite impact summary - -| Phase | Tests added | Tests changed | Tests deleted | -|---|---|---|---| -| 1 | `tests/Draft/DraftTest.php` | `GeneratorConfigurationTest` (new `setDraft` test) | — | -| 2 | — | `PropertyProcessorFactoryTest` (remove `PropertyMetaDataCollection` arg) | — | -| 3 | `tests/Draft/Modifier/TypeCheckModifierTest.php`, `DefaultValueModifierTest.php` | Full suite regression | — | -| 4 | One unit test per new modifier | All type-specific integration tests (regression) | — | -| 5 | New multi-type edge cases in `MultiTypePropertyTest` | `MultiTypePropertyTest` | — | -| 6 | — | `ObjectPropertyTest`, `IdenticalNestedSchemaTest` | — | -| 7 | — | All `ComposedValue/*Test`, all composition-related issue tests | — | -| 8 | `tests/Draft/DraftRegistryTest.php` | — | `PropertyProcessorFactoryTest` | - ---- - -## Completion criteria - -Each phase is complete when: -1. All new/changed code passes `./vendor/bin/phpcs --standard=phpcs.xml` -2. `./vendor/bin/phpunit` is fully green -3. The plan file is updated with "DONE" on that phase -4. The phase is committed as a standalone PR - -The entire rework is complete when Phase 8 is merged and this tracking directory is deleted -before the merge to `master`. diff --git a/src/PropertyProcessor/PropertyFactory.php b/src/PropertyProcessor/PropertyFactory.php index d9912508..22b6fbbd 100644 --- a/src/PropertyProcessor/PropertyFactory.php +++ b/src/PropertyProcessor/PropertyFactory.php @@ -363,7 +363,6 @@ private function createMultiTypeProperty( $subSchema, $type, $required, - $json, ); $subProperty->onResolve(function () use ( @@ -425,7 +424,6 @@ private function createSubTypeProperty( JsonSchema $propertySchema, string $type, bool $required, - array $parentJson, ): Property { $subProperty = $this->buildProperty( $schemaProcessor, @@ -461,7 +459,7 @@ private function finalizeMultiTypeProperty( $hasNull = in_array('null', $collectedTypes, true); $nonNullTypes = array_values(array_filter( $collectedTypes, - static fn(string $t): bool => $t !== 'null', + static fn(string $type): bool => $type !== 'null', )); $allowImplicitNull = $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() @@ -481,7 +479,7 @@ private function finalizeMultiTypeProperty( $property->addTypeHintDecorator(new TypeHintDecorator($typeHints)); - $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema, anyOnly: true); + $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema, true); } /**