From e34b58ecfcb41cf9c56096998a5b87783156d69d Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 28 May 2026 23:19:14 +0200 Subject: [PATCH 01/51] Add unevaluatedProperties activation skeleton (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the scaffolding needed for unevaluatedProperties/unevaluatedItems tracking without changing any existing validation behaviour: - AbstractComposedPropertyValidator: add trackEvaluation flag (getter/setter) so later phases can gate _compositionEvaluations cache emission per validator. - Schema: add postCompositionValidators list (addPostCompositionValidator / getPostCompositionValidators) for validators that must run after all composition validators complete. - Model.phptpl: extend executeBaseValidators with a third bucket that iterates schema.getPostCompositionValidators(); update both guard conditions to trigger the method when either the base-validator list or the post-composition list is non-empty. Template variables are not used for the new bucket — the schema object is already in context and the method is called directly. - UnevaluatedPropertiesPostProcessor (new internal post processor): activation walk via DFS over composition branches and nested schemas, breaking cycles through a file+pointer seen-set; sets trackEvaluation on matching validators and emits the _compositionEvaluations cache property when activation triggers. - ModelGenerator: register UnevaluatedPropertiesPostProcessor immediately after CompositionValidationPostProcessor. Co-Authored-By: Claude Sonnet 4.6 --- src/Model/Schema.php | 17 +++ .../AbstractComposedPropertyValidator.php | 16 +++ src/ModelGenerator.php | 4 +- .../UnevaluatedPropertiesPostProcessor.php | 114 ++++++++++++++++++ src/Templates/Model.phptpl | 8 +- 5 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php diff --git a/src/Model/Schema.php b/src/Model/Schema.php index b15de802..48483830 100644 --- a/src/Model/Schema.php +++ b/src/Model/Schema.php @@ -50,6 +50,8 @@ class Schema * before adding properties to the model */ protected $baseValidators = []; + /** @var PropertyValidatorInterface[] Validators that run after all composition validators */ + private array $postCompositionValidators = []; /** @var string[] */ protected $usedClasses = []; /** @var SchemaNamespaceTransferDecorator[] */ @@ -241,6 +243,21 @@ public function addBaseValidator(PropertyValidatorInterface $baseValidator): sel return $this; } + public function addPostCompositionValidator(PropertyValidatorInterface $postCompositionValidator): self + { + $this->postCompositionValidators[] = $postCompositionValidator; + + return $this; + } + + /** + * @return PropertyValidatorInterface[] + */ + public function getPostCompositionValidators(): array + { + return $this->postCompositionValidators; + } + public function getSchemaDictionary(): SchemaDefinitionDictionary { return $this->schemaDefinitionDictionary; diff --git a/src/Model/Validator/AbstractComposedPropertyValidator.php b/src/Model/Validator/AbstractComposedPropertyValidator.php index 93ae91ce..ff8ed9ee 100644 --- a/src/Model/Validator/AbstractComposedPropertyValidator.php +++ b/src/Model/Validator/AbstractComposedPropertyValidator.php @@ -18,6 +18,8 @@ abstract class AbstractComposedPropertyValidator extends ExtractedMethodValidato /** @var CompositionPropertyDecorator[] */ protected $composedProperties; + private bool $trackEvaluation = false; + public function getCompositionProcessor(): string { return $this->compositionProcessor; @@ -30,4 +32,18 @@ public function getComposedProperties(): array { return $this->composedProperties; } + + /** + * When true, this validator's rendered output will emit the _compositionEvaluations + * cache field and per-branch slot writes needed for unevaluatedProperties tracking. + */ + public function setTrackEvaluation(bool $trackEvaluation): void + { + $this->trackEvaluation = $trackEvaluation; + } + + public function isTrackEvaluation(): bool + { + return $this->trackEvaluation; + } } diff --git a/src/ModelGenerator.php b/src/ModelGenerator.php index 02c47ab2..f191798a 100644 --- a/src/ModelGenerator.php +++ b/src/ModelGenerator.php @@ -16,7 +16,8 @@ ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor, PatternPropertiesPostProcessor, SerializationPostProcessor, - TransformingFilterOutputTypePostProcessor + TransformingFilterOutputTypePostProcessor, + UnevaluatedPropertiesPostProcessor }; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; use PHPModelGenerator\SchemaProcessor\RenderQueue; @@ -45,6 +46,7 @@ public function __construct(protected GeneratorConfiguration $generatorConfigura // add internal post processors which must always be executed $this ->addPostProcessor(new CompositionValidationPostProcessor()) + ->addPostProcessor(new UnevaluatedPropertiesPostProcessor()) ->addPostProcessor(new AdditionalPropertiesPostProcessor()) ->addPostProcessor(new PatternPropertiesPostProcessor()) ->addPostProcessor(new ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor()) diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php new file mode 100644 index 00000000..dbf44093 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -0,0 +1,114 @@ +needsActivation($schema, $seen)) { + return; + } + + foreach ($schema->getBaseValidators() as $baseValidator) { + if ($baseValidator instanceof AbstractComposedPropertyValidator) { + $baseValidator->setTrackEvaluation(true); + } + } + + if (empty($schema->getCompositionValidatorKeys())) { + return; + } + + $schema->addProperty( + (new Property( + 'compositionEvaluations', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + )) + ->setInternal(true) + ->setDefaultValue([]), + ); + } + + /** + * Returns true when the schema or any reachable subschema contains unevaluatedProperties + * or unevaluatedItems, meaning composition validators on this schema must emit the + * _compositionEvaluations cache. + * + * Uses a $seen map keyed by file+pointer to break reference cycles. + */ + private function needsActivation(Schema $schema, array &$seen): bool + { + $schemaKey = $schema->getJsonSchema()->getFile() . '#' . $schema->getJsonSchema()->getPointer(); + + if (array_key_exists($schemaKey, $seen)) { + return $seen[$schemaKey]; + } + + // Mark false initially so cycles terminate without infinite recursion. + $seen[$schemaKey] = false; + + $json = $schema->getJsonSchema()->getJson(); + + if (array_key_exists('unevaluatedProperties', $json) || array_key_exists('unevaluatedItems', $json)) { + return $seen[$schemaKey] = true; + } + + // Check each composition branch: both the branch-level JSON and any nested schema it produces. + foreach ($schema->getBaseValidators() as $baseValidator) { + if (!$baseValidator instanceof AbstractComposedPropertyValidator) { + continue; + } + + foreach ($baseValidator->getComposedProperties() as $composedProperty) { + $branchJson = $composedProperty->getBranchSchema()->getJson(); + + if ( + array_key_exists('unevaluatedProperties', $branchJson) + || array_key_exists('unevaluatedItems', $branchJson) + ) { + return $seen[$schemaKey] = true; + } + + $nestedSchema = $composedProperty->getNestedSchema(); + + if ($nestedSchema !== null && $this->needsActivation($nestedSchema, $seen)) { + return $seen[$schemaKey] = true; + } + } + } + + // Check property-level nested schemas (e.g. an object-typed property with its own composition). + foreach ($schema->getProperties() as $schemaProperty) { + $nestedSchema = $schemaProperty->getNestedSchema(); + + if ($nestedSchema !== null && $this->needsActivation($nestedSchema, $seen)) { + return $seen[$schemaKey] = true; + } + } + + return $seen[$schemaKey] = false; + } +} diff --git a/src/Templates/Model.phptpl b/src/Templates/Model.phptpl index 7104550b..374327f4 100644 --- a/src/Templates/Model.phptpl +++ b/src/Templates/Model.phptpl @@ -62,7 +62,7 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v {{ schemaHookResolver.resolveConstructorBeforeValidationHook() }} - {% if schema.getBaseValidators() %} + {% if schema.getBaseValidators() or schema.getPostCompositionValidators() %} $this->executeBaseValidators($rawModelDataInput); {% endif %} @@ -83,7 +83,7 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v {{ schemaHookResolver.resolveConstructorAfterValidationHook() }} } - {% if schema.getBaseValidators() %} + {% if schema.getBaseValidators() or schema.getPostCompositionValidators() %} protected function executeBaseValidators(array &$modelData): void { $value = &$modelData; @@ -95,6 +95,10 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v {% foreach schema.getCompositionValidatorKeys() as compositionValidatorKey %} $this->validateComposition_{{ compositionValidatorKey }}($modelData); {% endforeach %} + + {% foreach schema.getPostCompositionValidators() as validator %} + {{ viewHelper.renderValidator(validator, schema) }} + {% endforeach %} } {% endif %} From 2f4c24682238a12b66c393041f9e00ed223ebd41 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 1 Jun 2026 09:53:41 +0200 Subject: [PATCH 02/51] Capture composition-branch evaluations on the generated class (Phase 2) When unevaluatedProperties or unevaluatedItems is reachable from a schema, each composition validator now records the per-branch evaluation outcome in a typed _compositionEvaluations slot: null when the branch failed, true when the branch evaluated every property (non-false additionalProperties), an array of evaluated property names for an inline branch, or the instantiated branch object for a nested-schema branch. The shared evaluation logic moved to the production-library CompositionEvaluationTrait so the slot writes don't duplicate the same loop across every generated class. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 4 + .../Property/CompositionPropertyDecorator.php | 49 ++++ .../AbstractComposedPropertyValidator.php | 22 +- .../Validator/ComposedPropertyValidator.php | 2 + .../ConditionalPropertyValidator.php | 2 + .../CompositionValidationPostProcessor.php | 10 + .../UnevaluatedPropertiesPostProcessor.php | 79 ++++++- src/Templates/Validator/ComposedItem.phptpl | 104 +++++--- .../Validator/ConditionalComposedItem.phptpl | 116 ++++++++- ...uatedPropertiesCompositionTrackingTest.php | 222 ++++++++++++++++++ .../AllOfInlineBranches.json | 25 ++ .../AnyOfInlineBranches.json | 26 ++ .../IfThenElseInlineBranches.json | 28 +++ .../NotInlineBranch.json | 19 ++ 14 files changed, 669 insertions(+), 39 deletions(-) create mode 100644 tests/Basic/UnevaluatedPropertiesCompositionTrackingTest.php create mode 100644 tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AllOfInlineBranches.json create mode 100644 tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AnyOfInlineBranches.json create mode 100644 tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/IfThenElseInlineBranches.json create mode 100644 tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/NotInlineBranch.json diff --git a/CLAUDE.md b/CLAUDE.md index 4654ba1a..b9ca1a62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -349,6 +349,10 @@ meaning. - ✅ `* Validates the zero-overlap rejection path in FilterValidator` - ❌ `* exercises FilterProcessor line 429 (else branch of classifyValidatorAdjustments)` - ✅ `* exercises the else branch of classifyValidatorAdjustments` +- ❌ `// Decision 0.3: Also harvest inline branch property names` +- ❌ `// Decision 0.6 unconditional rollback` +- ❌ `// Phase 3's UnevaluatedPropertiesValidator can query...` +- ❌ `// not with inline branch — Decision 0.6: slot permanently success=false` This rule applies equally to DocBlocks in test files: do not reference specific line numbers of the code under test. Line numbers shift whenever the file is edited, making such references diff --git a/src/Model/Property/CompositionPropertyDecorator.php b/src/Model/Property/CompositionPropertyDecorator.php index a87f1f4b..90f0367c 100644 --- a/src/Model/Property/CompositionPropertyDecorator.php +++ b/src/Model/Property/CompositionPropertyDecorator.php @@ -81,4 +81,53 @@ public function getBranchSchema(): JsonSchema { return $this->jsonSchema; } + + /** + * Returns the property names declared in this branch's `properties` keyword. + * + * Used by composition templates to compute the evaluated property set for a successful + * inline branch: each declared property name present in the model is credited as evaluated. + * + * @return string[] + */ + public function getBranchDeclaredPropertyNames(): array + { + return array_keys($this->jsonSchema->getJson()['properties'] ?? []); + } + + /** + * Returns base64-encoded patternProperties patterns declared in this branch's JSON schema. + * + * The patterns are base64-encoded so they can be safely embedded as PHP string literals + * in templates without escaping concerns. Templates decode them with base64_decode() before + * calling preg_match(). + * + * @return string[] + */ + public function getBranchPatternPropertyPatterns(): array + { + $patterns = []; + + foreach (array_keys($this->jsonSchema->getJson()['patternProperties'] ?? []) as $pattern) { + $patterns[] = base64_encode($pattern); + } + + return $patterns; + } + + /** + * Returns true if this branch's JSON schema explicitly declares an `additionalProperties` + * value that is not `false` (i.e., `true` or a schema object). + * + * Absent `additionalProperties` returns false: omitting the keyword does not contribute + * annotation results that unevaluatedProperties checks. When this method returns true, + * a successful branch is treated as evaluating every model key. + */ + public function branchHasNonFalseAdditionalProperties(): bool + { + $branchJson = $this->jsonSchema->getJson(); + + return isset($branchJson['additionalProperties']) + && $branchJson['additionalProperties'] !== false; + } } diff --git a/src/Model/Validator/AbstractComposedPropertyValidator.php b/src/Model/Validator/AbstractComposedPropertyValidator.php index ff8ed9ee..743deb93 100644 --- a/src/Model/Validator/AbstractComposedPropertyValidator.php +++ b/src/Model/Validator/AbstractComposedPropertyValidator.php @@ -5,6 +5,7 @@ namespace PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Property\CompositionPropertyDecorator; +use PHPModelGenerator\Model\Validator\Factory\Composition\NotValidatorFactory; /** * Class AbstractComposedPropertyValidator @@ -18,7 +19,7 @@ abstract class AbstractComposedPropertyValidator extends ExtractedMethodValidato /** @var CompositionPropertyDecorator[] */ protected $composedProperties; - private bool $trackEvaluation = false; + private bool $evaluationTrackingEnabled = false; public function getCompositionProcessor(): string { @@ -37,13 +38,24 @@ public function getComposedProperties(): array * When true, this validator's rendered output will emit the _compositionEvaluations * cache field and per-branch slot writes needed for unevaluatedProperties tracking. */ - public function setTrackEvaluation(bool $trackEvaluation): void + public function enableEvaluationTracking(): void { - $this->trackEvaluation = $trackEvaluation; + $this->evaluationTrackingEnabled = true; } - public function isTrackEvaluation(): bool + public function hasEvaluationTrackingEnabled(): bool { - return $this->trackEvaluation; + return $this->evaluationTrackingEnabled; + } + + /** + * Returns true when this validator implements `not` composition semantics. + * + * When true, composition templates unconditionally roll back _compositionEvaluations + * after the not-branch runs so that any annotations it wrote cannot leak to the parent. + */ + public function isNotComposition(): bool + { + return $this->compositionProcessor === NotValidatorFactory::class; } } diff --git a/src/Model/Validator/ComposedPropertyValidator.php b/src/Model/Validator/ComposedPropertyValidator.php index c67938f9..c832fb7c 100644 --- a/src/Model/Validator/ComposedPropertyValidator.php +++ b/src/Model/Validator/ComposedPropertyValidator.php @@ -51,6 +51,8 @@ public function __construct( */ public function getCheck(): string { + $this->templateValues['compositionValidator'] = $this; + $hasNestedSchemaWithProperties = $this->hasNestedSchemaWithProperties(); $this->templateValues['hasModifiedValuesMethod'] = $hasNestedSchemaWithProperties; diff --git a/src/Model/Validator/ConditionalPropertyValidator.php b/src/Model/Validator/ConditionalPropertyValidator.php index 6bc5040e..df5c50b0 100644 --- a/src/Model/Validator/ConditionalPropertyValidator.php +++ b/src/Model/Validator/ConditionalPropertyValidator.php @@ -41,6 +41,8 @@ public function __construct( $this->compositionProcessor = IfValidatorFactory::class; $this->composedProperties = $composedProperties; $this->conditionBranches = $conditionBranches; + + $this->templateValues['compositionValidator'] = $this; } /** diff --git a/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php index 37290efe..a96c058e 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php @@ -64,6 +64,16 @@ private function generateValidatorPropertyMap(Schema $schema): array foreach ($validator->getComposedProperties() as $composedProperty) { if ($composedProperty->getNestedSchema() === null) { + // Harvest inline branch declared property names so setter-side revalidation + // fires when those properties change. Without this, updating a property that + // only an inline branch's `properties` declares would leave _compositionEvaluations + // stale. + foreach ($composedProperty->getBranchDeclaredPropertyNames() as $propertyName) { + if (!isset($validatorPropertyMap[$propertyName])) { + $validatorPropertyMap[$propertyName] = []; + } + $validatorPropertyMap[$propertyName][] = $validatorIndex; + } continue; } diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php index dbf44093..12496687 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -5,19 +5,22 @@ namespace PHPModelGenerator\SchemaProcessor\PostProcessor\Internal; use PHPModelGenerator\Model\GeneratorConfiguration; +use PHPModelGenerator\Model\MethodInterface; 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\AbstractComposedPropertyValidator; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; +use PHPModelGenerator\Traits\CompositionEvaluationTrait; /** * Detects whether unevaluatedProperties or unevaluatedItems is reachable from a schema * and, when it is, activates evaluation tracking on that schema's composition validators. * * Activation emits the _compositionEvaluations cache field on the generated class and marks - * each composition validator so that later phases can extend it with per-branch slot writes. + * each composition validator with per-branch slot writes. * When neither keyword is reachable anywhere in the schema graph, this post processor is a * complete no-op and generates no extra code. */ @@ -33,7 +36,7 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu foreach ($schema->getBaseValidators() as $baseValidator) { if ($baseValidator instanceof AbstractComposedPropertyValidator) { - $baseValidator->setTrackEvaluation(true); + $baseValidator->enableEvaluationTracking(); } } @@ -41,6 +44,8 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu return; } + $schema->addTrait(CompositionEvaluationTrait::class); + $schema->addProperty( (new Property( 'compositionEvaluations', @@ -50,6 +55,76 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu ->setInternal(true) ->setDefaultValue([]), ); + + $this->addGetEvaluatedPropertiesToNestedBranchSchemas($schema); + } + + /** + * For each composition branch that produces a nested object class, adds a + * getEvaluatedProperties() method so the unevaluatedProperties validator can query + * which properties the nested class evaluated. + */ + private function addGetEvaluatedPropertiesToNestedBranchSchemas(Schema $schema): void + { + foreach ($schema->getBaseValidators() as $baseValidator) { + if (!$baseValidator instanceof AbstractComposedPropertyValidator) { + continue; + } + + foreach ($baseValidator->getComposedProperties() as $composedProperty) { + $nestedSchema = $composedProperty->getNestedSchema(); + + if ($nestedSchema === null || $nestedSchema->hasMethod('getEvaluatedProperties')) { + continue; + } + + $nestedSchema->addMethod( + 'getEvaluatedProperties', + $this->buildGetEvaluatedPropertiesMethod($nestedSchema), + ); + } + } + } + + /** + * Builds a MethodInterface that emits getEvaluatedProperties() for the given nested schema. + * + * The method returns all declared property names (from the nested schema's `properties` + * keyword) that are present in the instance's raw model data at the time of the call. + */ + private function buildGetEvaluatedPropertiesMethod(Schema $nestedSchema): MethodInterface + { + return new class ($nestedSchema) implements MethodInterface { + public function __construct(private readonly Schema $nestedSchema) + { + } + + public function getCode(): string + { + $declaredPropertyNames = array_values(array_map( + static fn(PropertyInterface $property): string => $property->getName(), + array_filter( + $this->nestedSchema->getProperties(), + static fn(PropertyInterface $property): bool => !$property->isInternal(), + ), + )); + + return sprintf( + ' + public function getEvaluatedProperties(): array + { + $evaluated = []; + foreach (%s as $propName) { + if (array_key_exists($propName, $this->_rawModelDataInput)) { + $evaluated[$propName] = true; + } + } + return array_keys($evaluated); + }', + var_export($declaredPropertyNames, true), + ); + } + }; } /** diff --git a/src/Templates/Validator/ComposedItem.phptpl b/src/Templates/Validator/ComposedItem.phptpl index 54c36986..08c15c7f 100644 --- a/src/Templates/Validator/ComposedItem.phptpl +++ b/src/Templates/Validator/ComposedItem.phptpl @@ -18,11 +18,19 @@ $originalPropertyValidationState = $this->_propertyValidationState ?? []; {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + $originalCompositionEvaluations = $this->_compositionEvaluations ?? []; + {% endif %} + {% if generatorConfiguration.collectErrors() %} $originalErrorRegistry = $this->_errorRegistry; {% endif %} {% foreach compositionProperties as compositionProperty %} + {% if compositionValidator.isNotComposition() and compositionValidator.hasEvaluationTrackingEnabled() %} + $preNotBranchEvaluations = $this->_compositionEvaluations ?? []; + {% endif %} + try { {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} // check if the state of the validator is already known. @@ -53,46 +61,47 @@ } else { {% endif %} - {% if generatorConfiguration.collectErrors() %} - // collect errors for each composition element - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); - {% endif %} - - {% if not postPropose %} - $proposedValue = $proposedValue ?? $value; - {% endif %} + {% if generatorConfiguration.collectErrors() %} + // collect errors for each composition element + $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% endif %} - {{ viewHelper.resolvePropertyDecorator(compositionProperty) }} + {% if not postPropose %} + $proposedValue = $proposedValue ?? $value; + {% endif %} - {% foreach compositionProperty.getOrderedValidators() as validator %} - {{ viewHelper.renderValidator(validator, schema) }} - {% endforeach %} + {{ viewHelper.resolvePropertyDecorator(compositionProperty) }} - {% if generatorConfiguration.collectErrors() %} - $compositionErrorCollection[] = $this->_errorRegistry; + {% foreach compositionProperty.getOrderedValidators() as validator %} + {{ viewHelper.renderValidator(validator, schema) }} + {% endforeach %} - {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} - if (isset($validatorIndex)) { - $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex] = $this->_errorRegistry; - } - {% endif %} + {% if generatorConfiguration.collectErrors() %} + $compositionErrorCollection[] = $this->_errorRegistry; - // an error inside the composed validation occurred. Throw an exception to count the validity of the - // composition item - if ($this->_errorRegistry->getErrors()) { - throw new \Exception(); + {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} + if (isset($validatorIndex)) { + $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex] = $this->_errorRegistry; } {% endif %} - {% if postPropose %} - $proposedValue = $proposedValue ?? $value; - {% endif %} + // an error inside the composed validation occurred. Throw an exception to count the validity of the + // composition item + if ($this->_errorRegistry->getErrors()) { + throw new \Exception(); + } + {% endif %} + + {% if postPropose %} + $proposedValue = $proposedValue ?? $value; + {% endif %} + + {% if hasModifiedValuesMethod %} + if (is_object($value)) { + $modifiedValues = array_merge($modifiedValues, $this->{{ modifiedValuesMethod }}($originalModelData, $value)); + } + {% endif %} - {% if hasModifiedValuesMethod %} - if (is_object($value)) { - $modifiedValues = array_merge($modifiedValues, $this->{{ modifiedValuesMethod }}($originalModelData, $value)); - } - {% endif %} {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} {% if not generatorConfiguration.collectErrors() %} if (isset($validatorIndex)) { @@ -101,6 +110,22 @@ {% endif %} } {% endif %} + + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if compositionProperty.branchHasNonFalseAdditionalProperties() %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = true; + {% else %} + {% if compositionProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = is_object($value) ? $value : null; + {% else %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = $this->collectEvaluatedProperties( + $modelData, + [{% foreach compositionProperty.getBranchDeclaredPropertyNames() as propName %}'{{ propName }}', {% endforeach %}], + [{% foreach compositionProperty.getBranchPatternPropertyPatterns() as encodedPattern %}'{{ encodedPattern }}', {% endforeach %}], + ); + {% endif %} + {% endif %} + {% endif %} } catch (\Exception $e) { {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) and not generatorConfiguration.collectErrors() @@ -110,9 +135,20 @@ } {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = null; + {% endif %} + $succeededCompositionElements--; } + {% if compositionValidator.isNotComposition() and compositionValidator.hasEvaluationTrackingEnabled() %} + // Restore composition evaluations so any annotations written by the not-branch body + // (including by nested UnevaluatedPropertiesValidators) cannot leak to the parent schema. + $this->_compositionEvaluations = $preNotBranchEvaluations; + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = null; + {% endif %} + $value = $originalModelData; $validatorComponentIndex++; {% endforeach %} @@ -143,5 +179,11 @@ } {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + if ($result) { + $this->_compositionEvaluations = $originalCompositionEvaluations; + } + {% endif %} + return $result; })($value) diff --git a/src/Templates/Validator/ConditionalComposedItem.phptpl b/src/Templates/Validator/ConditionalComposedItem.phptpl index 8bb03700..11489053 100644 --- a/src/Templates/Validator/ConditionalComposedItem.phptpl +++ b/src/Templates/Validator/ConditionalComposedItem.phptpl @@ -5,7 +5,8 @@ &$modelData, &$ifException, &$thenException, - &$elseException + &$elseException, + &$validatorIndex ) { $originalModelData = $value; {% if generatorConfiguration.collectErrors() %} @@ -13,6 +14,12 @@ $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if ifProperty.getNestedSchema() %} + $capturedIfInstance = null; + {% endif %} + {% endif %} + try { {{ viewHelper.resolvePropertyDecorator(ifProperty) }} @@ -25,17 +32,50 @@ throw $this->_errorRegistry; } {% endif %} + + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if ifProperty.getNestedSchema() %} + $capturedIfInstance = is_object($value) ? $value : null; + {% endif %} + {% endif %} } catch (\Exception $e) { $ifException = $e; } + $value = $originalModelData; + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + if ($ifException) { + $this->_compositionEvaluations[$validatorIndex][0] = null; + } else { + {% if ifProperty.branchHasNonFalseAdditionalProperties() %} + $this->_compositionEvaluations[$validatorIndex][0] = true; + {% else %} + {% if ifProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][0] = $capturedIfInstance; + {% else %} + $this->_compositionEvaluations[$validatorIndex][0] = $this->collectEvaluatedProperties( + $modelData, + [{% foreach ifProperty.getBranchDeclaredPropertyNames() as propName %}'{{ propName }}', {% endforeach %}], + [{% foreach ifProperty.getBranchPatternPropertyPatterns() as encodedPattern %}'{{ encodedPattern }}', {% endforeach %}], + ); + {% endif %} + {% endif %} + } + {% endif %} + {% if generatorConfiguration.collectErrors() %} $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} if (!$ifException) { {% if thenProperty %} + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if thenProperty.getNestedSchema() %} + $capturedThenInstance = null; + {% endif %} + {% endif %} + try { {{ viewHelper.resolvePropertyDecorator(thenProperty) }} @@ -48,12 +88,52 @@ throw $this->_errorRegistry; } {% endif %} + + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if thenProperty.getNestedSchema() %} + $capturedThenInstance = is_object($value) ? $value : null; + {% endif %} + {% endif %} } catch (\Exception $e) { $thenException = $e; } + + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + if ($thenException) { + $this->_compositionEvaluations[$validatorIndex][1] = null; + } else { + {% if thenProperty.branchHasNonFalseAdditionalProperties() %} + $this->_compositionEvaluations[$validatorIndex][1] = true; + {% else %} + {% if thenProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][1] = $capturedThenInstance; + {% else %} + $this->_compositionEvaluations[$validatorIndex][1] = $this->collectEvaluatedProperties( + $modelData, + [{% foreach thenProperty.getBranchDeclaredPropertyNames() as propName %}'{{ propName }}', {% endforeach %}], + [{% foreach thenProperty.getBranchPatternPropertyPatterns() as encodedPattern %}'{{ encodedPattern }}', {% endforeach %}], + ); + {% endif %} + {% endif %} + } + {% endif %} + {% else %} + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + $this->_compositionEvaluations[$validatorIndex][1] = null; + {% endif %} + {% endif %} + + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + $this->_compositionEvaluations[$validatorIndex][2] = null; {% endif %} } else { {% if elseProperty %} + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if elseProperty.getNestedSchema() %} + $capturedElseInstance = null; + {% endif %} + {% endif %} + try { {{ viewHelper.resolvePropertyDecorator(elseProperty) }} @@ -66,9 +146,43 @@ throw $this->_errorRegistry; } {% endif %} + + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if elseProperty.getNestedSchema() %} + $capturedElseInstance = is_object($value) ? $value : null; + {% endif %} + {% endif %} } catch (\Exception $e) { $elseException = $e; } + + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + if ($elseException) { + $this->_compositionEvaluations[$validatorIndex][2] = null; + } else { + {% if elseProperty.branchHasNonFalseAdditionalProperties() %} + $this->_compositionEvaluations[$validatorIndex][2] = true; + {% else %} + {% if elseProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][2] = $capturedElseInstance; + {% else %} + $this->_compositionEvaluations[$validatorIndex][2] = $this->collectEvaluatedProperties( + $modelData, + [{% foreach elseProperty.getBranchDeclaredPropertyNames() as propName %}'{{ propName }}', {% endforeach %}], + [{% foreach elseProperty.getBranchPatternPropertyPatterns() as encodedPattern %}'{{ encodedPattern }}', {% endforeach %}], + ); + {% endif %} + {% endif %} + } + {% endif %} + {% else %} + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + $this->_compositionEvaluations[$validatorIndex][2] = null; + {% endif %} + {% endif %} + + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + $this->_compositionEvaluations[$validatorIndex][1] = null; {% endif %} } diff --git a/tests/Basic/UnevaluatedPropertiesCompositionTrackingTest.php b/tests/Basic/UnevaluatedPropertiesCompositionTrackingTest.php new file mode 100644 index 00000000..30a2c0cb --- /dev/null +++ b/tests/Basic/UnevaluatedPropertiesCompositionTrackingTest.php @@ -0,0 +1,222 @@ + — branch succeeded, inline branch, list of evaluated property names + * object — branch succeeded, nested schema instance + */ +#[ApplicableDrafts(from: JsonSchemaDraft::DRAFT_2019_09)] +class UnevaluatedPropertiesCompositionTrackingTest extends AbstractPHPModelGeneratorTestCase +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function getCompositionEvaluations(object $instance): array + { + $reflection = new ReflectionProperty(get_class($instance), '_compositionEvaluations'); + $reflection->setAccessible(true); + + return $reflection->getValue($instance); + } + + /** + * Returns the branch slots for the single composition validator in the schema. + * + * The outer key (the validator index in baseValidators) is not assumed to be 0 — we + * take the first value so tests are not coupled to the order validators are registered. + * + * Each slot is a typed value: null (failed), true (all evaluated), array + * (inline evaluated names), or object (nested schema instance). + * + * @return array + */ + private function getSingleCompositorBranches(object $instance): array + { + $evaluations = $this->getCompositionEvaluations($instance); + $this->assertCount(1, $evaluations, 'Expected exactly one composition validator'); + + return array_values($evaluations)[0]; + } + + // ------------------------------------------------------------------------- + // Presence / absence of _compositionEvaluations + // ------------------------------------------------------------------------- + + public function testSchemaWithUnevaluatedPropertiesHasCompositionEvaluationsProperty(): void + { + $className = $this->generateClassFromFile('AllOfInlineBranches.json'); + + $this->assertTrue( + (new ReflectionClass($className))->hasProperty('_compositionEvaluations'), + '_compositionEvaluations must be emitted when unevaluatedProperties activates tracking', + ); + } + + public function testSchemaWithoutUnevaluatedPropertiesHasNoCompositionEvaluationsProperty(): void + { + // Identical allOf structure but without unevaluatedProperties — no tracking emitted. + $className = $this->generateClass(json_encode([ + 'type' => 'object', + 'allOf' => [ + ['properties' => ['foo' => ['type' => 'string']]], + ['properties' => ['bar' => ['type' => 'integer']]], + ], + ])); + + $this->assertFalse( + (new ReflectionClass($className))->hasProperty('_compositionEvaluations'), + '_compositionEvaluations must NOT be emitted when unevaluatedProperties is absent', + ); + } + + // ------------------------------------------------------------------------- + // allOf with inline branches + // ------------------------------------------------------------------------- + + public function testAllOfInlineBranchesWithBothPropertiesPresent(): void + { + $className = $this->generateClassFromFile('AllOfInlineBranches.json'); + $instance = new $className(['name' => 'Alice', 'foo' => 'hello', 'bar' => 42]); + + $branches = $this->getSingleCompositorBranches($instance); + + // The parent schema has `type: object`, so inheritPropertyType propagates that type into + // every branch, causing each branch to become a nested-object class rather than inline. + // Nested-object branches produce an object slot holding the instantiated branch object. + + // Branch 0 (foo) — allOf always succeeds; produces a nested instance + $this->assertIsObject($branches[0]); + + // Branch 1 (bar) — allOf always succeeds; produces a nested instance + $this->assertIsObject($branches[1]); + } + + public function testAllOfInlineBranchesWithNoOptionalPropertiesEvaluatesEmptySets(): void + { + $className = $this->generateClassFromFile('AllOfInlineBranches.json'); + $instance = new $className(['name' => 'Alice']); + + $branches = $this->getSingleCompositorBranches($instance); + + // Both branches succeed (no required constraints) and produce nested instances even when + // no optional properties are present — the nested object simply has no recognised values. + $this->assertIsObject($branches[0]); + $this->assertIsObject($branches[1]); + } + + // ------------------------------------------------------------------------- + // anyOf with inline branches + // ------------------------------------------------------------------------- + + public function testAnyOfInlineBranchesWithFirstBranchMatching(): void + { + $className = $this->generateClassFromFile('AnyOfInlineBranches.json'); + $instance = new $className(['kind' => 'A']); + + $branches = $this->getSingleCompositorBranches($instance); + + // Branch 0 (requires kind) — succeeds; nested instance produced + $this->assertIsObject($branches[0]); + + // Branch 1 (requires value) — fails (value absent) + $this->assertNull($branches[1]); + } + + public function testAnyOfInlineBranchesWithSecondBranchMatching(): void + { + $className = $this->generateClassFromFile('AnyOfInlineBranches.json'); + $instance = new $className(['value' => 99]); + + $branches = $this->getSingleCompositorBranches($instance); + + // Branch 0 (requires kind) — fails (kind absent) + $this->assertNull($branches[0]); + + // Branch 1 (requires value) — succeeds; nested instance produced + $this->assertIsObject($branches[1]); + } + + // ------------------------------------------------------------------------- + // not with inline branch — slot is permanently null (annotations must not leak) + // ------------------------------------------------------------------------- + + public function testNotInlineBranchSlotIsPermanentlyNullWhenNotConditionPasses(): void + { + $className = $this->generateClassFromFile('NotInlineBranch.json'); + // forbidden absent: not-condition is satisfied (object does NOT match the not-schema) + $instance = new $className(['foo' => 'hello']); + + $branches = $this->getSingleCompositorBranches($instance); + + // The not-branch slot is always null: after the not-branch runs, composition evaluations + // are unconditionally rolled back so that any annotations the not-branch wrote cannot + // propagate. The slot is then written as null regardless of whether the branch matched. + $this->assertNull($branches[0]); + } + + public function testNotInlineBranchThrowsWhenNotConditionFails(): void + { + $this->expectException(ValidationException::class); + + $className = $this->generateClassFromFile('NotInlineBranch.json'); + // forbidden present: the not-condition fails (object DOES match the not-schema) + new $className(['foo' => 'hello', 'forbidden' => 'x']); + } + + // ------------------------------------------------------------------------- + // if/then/else with inline branches — slot layout: 0=if, 1=then, 2=else + // ------------------------------------------------------------------------- + + public function testIfThenElseWithIfConditionMatching(): void + { + $className = $this->generateClassFromFile('IfThenElseInlineBranches.json'); + $instance = new $className(['kind' => 'A', 'valueA' => 'hello']); + + $branches = $this->getSingleCompositorBranches($instance); + + // Slot 0 (if) — condition matched; nested instance produced for if-branch + $this->assertIsObject($branches[0]); + + // Slot 1 (then) — ran and succeeded; nested instance produced for then-branch + $this->assertIsObject($branches[1]); + + // Slot 2 (else) — not executed; null + $this->assertNull($branches[2]); + } + + public function testIfThenElseWithIfConditionNotMatching(): void + { + $className = $this->generateClassFromFile('IfThenElseInlineBranches.json'); + $instance = new $className(['kind' => 'B', 'valueB' => 7]); + + $branches = $this->getSingleCompositorBranches($instance); + + // Slot 0 (if) — condition did not match; null + $this->assertNull($branches[0]); + + // Slot 1 (then) — not executed; null + $this->assertNull($branches[1]); + + // Slot 2 (else) — ran and succeeded; nested instance produced for else-branch + $this->assertIsObject($branches[2]); + } +} diff --git a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AllOfInlineBranches.json b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AllOfInlineBranches.json new file mode 100644 index 00000000..9d3ecb68 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AllOfInlineBranches.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "properties": { + "bar": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AnyOfInlineBranches.json b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AnyOfInlineBranches.json new file mode 100644 index 00000000..c30c06aa --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AnyOfInlineBranches.json @@ -0,0 +1,26 @@ +{ + "type": "object", + "anyOf": [ + { + "properties": { + "kind": { + "type": "string" + } + }, + "required": [ + "kind" + ] + }, + { + "properties": { + "value": { + "type": "integer" + } + }, + "required": [ + "value" + ] + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/IfThenElseInlineBranches.json b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/IfThenElseInlineBranches.json new file mode 100644 index 00000000..b5c9bfb0 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/IfThenElseInlineBranches.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "if": { + "properties": { + "kind": { + "const": "A" + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "valueA": { + "type": "string" + } + } + }, + "else": { + "properties": { + "valueB": { + "type": "integer" + } + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/NotInlineBranch.json b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/NotInlineBranch.json new file mode 100644 index 00000000..e4722259 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/NotInlineBranch.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "foo": { + "type": "string" + } + }, + "not": { + "properties": { + "forbidden": { + "type": "string" + } + }, + "required": [ + "forbidden" + ] + }, + "unevaluatedProperties": false +} From 349d4ab0ad10d46d7f28e3b5dced6d9484abbd2d Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 1 Jun 2026 14:28:08 +0200 Subject: [PATCH 03/51] Optimize composition evaluation tracking Three improvements layered on the Phase 2 tracking infrastructure: Pre-decode patternProperties patterns at generation time. CompositionPropertyDecorator now exposes getBranchDeclaredPropertyNamesPhpLiteral() and getBranchPatternPropertyPatternsPhpLiteral() returning var_export'd PHP array literals ready for direct template embedding. Templates drop the per-name quoting foreach loop, and the trait no longer calls base64_decode on every preg_match. var_export also handles all string escaping, so property names containing single quotes or backslashes no longer produce broken generated code. Skip the tracking write on a cache hit. ComposedItem.phptpl initializes $shouldTrack = true per branch iteration; the mutable-validator cache-hit branch sets it to false so the success-tracking block is bypassed and the slot value from the previous run is preserved. On a cache hit $value still holds the pre-validator array rather than the nested-schema object, so the old unconditional write would overwrite a valid object slot with null. Co-Authored-By: Claude Opus 4.7 --- .../Property/CompositionPropertyDecorator.php | 35 ++++++++++--------- src/Templates/Validator/ComposedItem.phptpl | 35 +++++++++++++------ .../Validator/ConditionalComposedItem.phptpl | 12 +++---- 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/src/Model/Property/CompositionPropertyDecorator.php b/src/Model/Property/CompositionPropertyDecorator.php index 90f0367c..b498686e 100644 --- a/src/Model/Property/CompositionPropertyDecorator.php +++ b/src/Model/Property/CompositionPropertyDecorator.php @@ -85,8 +85,8 @@ public function getBranchSchema(): JsonSchema /** * Returns the property names declared in this branch's `properties` keyword. * - * Used by composition templates to compute the evaluated property set for a successful - * inline branch: each declared property name present in the model is credited as evaluated. + * Used by the composition post processor to harvest names that must invalidate the + * setter-side validation cache when those properties change. * * @return string[] */ @@ -96,23 +96,26 @@ public function getBranchDeclaredPropertyNames(): array } /** - * Returns base64-encoded patternProperties patterns declared in this branch's JSON schema. - * - * The patterns are base64-encoded so they can be safely embedded as PHP string literals - * in templates without escaping concerns. Templates decode them with base64_decode() before - * calling preg_match(). - * - * @return string[] + * Returns the declared property names as a PHP array literal ready for direct template + * embedding. var_export emits a syntactically-valid PHP literal regardless of which + * characters the names contain (quotes, backslashes, multibyte sequences). */ - public function getBranchPatternPropertyPatterns(): array + public function getBranchDeclaredPropertyNamesPhpLiteral(): string { - $patterns = []; - - foreach (array_keys($this->jsonSchema->getJson()['patternProperties'] ?? []) as $pattern) { - $patterns[] = base64_encode($pattern); - } + return var_export($this->getBranchDeclaredPropertyNames(), true); + } - return $patterns; + /** + * Returns the patternProperties regexes as a PHP array literal ready for direct template + * embedding. var_export handles all string escaping so generated code does not need to + * round-trip patterns through base64 at runtime. + */ + public function getBranchPatternPropertyPatternsPhpLiteral(): string + { + return var_export( + array_keys($this->jsonSchema->getJson()['patternProperties'] ?? []), + true, + ); } /** diff --git a/src/Templates/Validator/ComposedItem.phptpl b/src/Templates/Validator/ComposedItem.phptpl index 08c15c7f..43a86ec9 100644 --- a/src/Templates/Validator/ComposedItem.phptpl +++ b/src/Templates/Validator/ComposedItem.phptpl @@ -31,6 +31,10 @@ $preNotBranchEvaluations = $this->_compositionEvaluations ?? []; {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + $shouldTrack = true; + {% endif %} + try { {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} // check if the state of the validator is already known. @@ -46,6 +50,13 @@ ] ) ) { + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + // Cache hit: validators are not re-run, so $value still holds the + // pre-validator array rather than a nested-schema object. Skip the + // tracking write to preserve the slot value from the previous run. + $shouldTrack = false; + {% endif %} + {% if generatorConfiguration.collectErrors() %} $compositionErrorCollection[] = $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex]; {% endif %} @@ -112,19 +123,21 @@ {% endif %} {% if compositionValidator.hasEvaluationTrackingEnabled() %} - {% if compositionProperty.branchHasNonFalseAdditionalProperties() %} - $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = true; - {% else %} - {% if compositionProperty.getNestedSchema() %} - $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = is_object($value) ? $value : null; + if ($shouldTrack) { + {% if compositionProperty.branchHasNonFalseAdditionalProperties() %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = true; {% else %} - $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = $this->collectEvaluatedProperties( - $modelData, - [{% foreach compositionProperty.getBranchDeclaredPropertyNames() as propName %}'{{ propName }}', {% endforeach %}], - [{% foreach compositionProperty.getBranchPatternPropertyPatterns() as encodedPattern %}'{{ encodedPattern }}', {% endforeach %}], - ); + {% if compositionProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = is_object($value) ? $value : null; + {% else %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = $this->collectEvaluatedProperties( + $modelData, + {{ compositionProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, + {{ compositionProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, + ); + {% endif %} {% endif %} - {% endif %} + } {% endif %} } catch (\Exception $e) { {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) diff --git a/src/Templates/Validator/ConditionalComposedItem.phptpl b/src/Templates/Validator/ConditionalComposedItem.phptpl index 11489053..78718278 100644 --- a/src/Templates/Validator/ConditionalComposedItem.phptpl +++ b/src/Templates/Validator/ConditionalComposedItem.phptpl @@ -56,8 +56,8 @@ {% else %} $this->_compositionEvaluations[$validatorIndex][0] = $this->collectEvaluatedProperties( $modelData, - [{% foreach ifProperty.getBranchDeclaredPropertyNames() as propName %}'{{ propName }}', {% endforeach %}], - [{% foreach ifProperty.getBranchPatternPropertyPatterns() as encodedPattern %}'{{ encodedPattern }}', {% endforeach %}], + {{ ifProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, + {{ ifProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, ); {% endif %} {% endif %} @@ -110,8 +110,8 @@ {% else %} $this->_compositionEvaluations[$validatorIndex][1] = $this->collectEvaluatedProperties( $modelData, - [{% foreach thenProperty.getBranchDeclaredPropertyNames() as propName %}'{{ propName }}', {% endforeach %}], - [{% foreach thenProperty.getBranchPatternPropertyPatterns() as encodedPattern %}'{{ encodedPattern }}', {% endforeach %}], + {{ thenProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, + {{ thenProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, ); {% endif %} {% endif %} @@ -168,8 +168,8 @@ {% else %} $this->_compositionEvaluations[$validatorIndex][2] = $this->collectEvaluatedProperties( $modelData, - [{% foreach elseProperty.getBranchDeclaredPropertyNames() as propName %}'{{ propName }}', {% endforeach %}], - [{% foreach elseProperty.getBranchPatternPropertyPatterns() as encodedPattern %}'{{ encodedPattern }}', {% endforeach %}], + {{ elseProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, + {{ elseProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, ); {% endif %} {% endif %} From d54bfd4e4ffde0a7982c3a9b70431b8adbb707e4 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Wed, 3 Jun 2026 17:53:11 +0200 Subject: [PATCH 04/51] Enforce unevaluatedProperties for Draft 2019-09 Adds the runtime validator for `unevaluatedProperties: false` and `unevaluatedProperties: ` on top of the existing composition evaluation tracking. The validator iterates the model keys left over after subtracting the local `properties`/`patternProperties` set plus the contributions of every successful sibling composition branch recorded in `_compositionEvaluations`. The factory skips emitting any validator when the same schema declares a non-false `additionalProperties`: that keyword already claims every remaining key, so unevaluatedProperties would be a no-op. The cross-schema modification needed for nested branch classes (getEvaluatedProperties) is now applied during `process()`. To keep that visible at render time, `RenderQueue::execute` runs every job's `process()` pass before any `render()` begins. The shared evaluated/unevaluated computation lives on `CompositionEvaluationTrait` in the production library; both validator templates collapse to a single trait-method call. Adds `RenderHelper::varExportPcrePatterns()` to wrap raw patternProperties keys with `/`-delimiters and escape embedded `/`. The existing patternProperties consumers (`AdditionalPropertiesValidator`, `AdditionalPropertiesPostProcessor`, `CompositionPropertyDecorator`) are migrated to it, fixing a latent crash on patterns containing slashes. Co-Authored-By: Claude Opus 4.7 --- src/Draft/Draft_2019_09.php | 4 + .../Property/CompositionPropertyDecorator.php | 8 +- ...AbstractUnevaluatedPropertiesValidator.php | 70 +++++++ .../AdditionalPropertiesValidator.php | 4 +- .../UnevaluatedPropertiesValidatorFactory.php | 56 ++++++ .../NoUnevaluatedPropertiesValidator.php | 31 +++ .../UnevaluatedPropertiesValidator.php | 86 ++++++++ .../AdditionalPropertiesPostProcessor.php | 2 +- .../UnevaluatedPropertiesPostProcessor.php | 27 ++- .../UpdateAdditionalProperties.phptpl | 2 +- src/SchemaProcessor/RenderQueue.php | 8 + .../Validator/AdditionalProperties.phptpl | 2 +- .../Validator/NoUnevaluatedProperties.phptpl | 6 + .../Validator/UnevaluatedProperties.phptpl | 44 +++++ src/Utils/RenderHelper.php | 17 ++ .../UnevaluatedPropertiesValidatorTest.php | 185 ++++++++++++++++++ .../IfThenElseInlineBranches.json | 5 + .../AdditionalPropertiesShortcut.json | 12 ++ .../AllOfBranchesCoverEvaluation.json | 25 +++ .../AnyOfSelectsSuccessfulBranches.json | 26 +++ .../IfThenElseEvaluation.json | 33 ++++ .../NoExtraPropertiesAllowed.json | 9 + .../NotBranchContributesNothing.json | 19 ++ .../PatternPropertiesEvaluated.json | 9 + .../SchemaFormUnevaluated.json | 11 ++ 25 files changed, 683 insertions(+), 18 deletions(-) create mode 100644 src/Model/Validator/AbstractUnevaluatedPropertiesValidator.php create mode 100644 src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php create mode 100644 src/Model/Validator/NoUnevaluatedPropertiesValidator.php create mode 100644 src/Model/Validator/UnevaluatedPropertiesValidator.php create mode 100644 src/Templates/Validator/NoUnevaluatedProperties.phptpl create mode 100644 src/Templates/Validator/UnevaluatedProperties.phptpl create mode 100644 tests/Basic/UnevaluatedPropertiesValidatorTest.php create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalPropertiesShortcut.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/AllOfBranchesCoverEvaluation.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfSelectsSuccessfulBranches.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenElseEvaluation.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/NoExtraPropertiesAllowed.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchContributesNothing.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/PatternPropertiesEvaluated.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/SchemaFormUnevaluated.json diff --git a/src/Draft/Draft_2019_09.php b/src/Draft/Draft_2019_09.php index 4996b3a6..3e7e71fb 100644 --- a/src/Draft/Draft_2019_09.php +++ b/src/Draft/Draft_2019_09.php @@ -5,6 +5,7 @@ namespace PHPModelGenerator\Draft; use PHPModelGenerator\Model\Validator\Factory\Arrays\ContainsValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Object\UnevaluatedPropertiesValidatorFactory; class Draft_2019_09 extends Draft_07 { @@ -15,6 +16,9 @@ public function getDefinition(): DraftBuilder $builder->getType('array') ->addValidator('contains', new ContainsValidatorFactory()); + $builder->getType('object') + ->addValidator('unevaluatedProperties', new UnevaluatedPropertiesValidatorFactory()); + return $builder; } } diff --git a/src/Model/Property/CompositionPropertyDecorator.php b/src/Model/Property/CompositionPropertyDecorator.php index b498686e..43f3b213 100644 --- a/src/Model/Property/CompositionPropertyDecorator.php +++ b/src/Model/Property/CompositionPropertyDecorator.php @@ -7,6 +7,7 @@ use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\SchemaDefinition\ResolvedDefinitionsCollection; +use PHPModelGenerator\Utils\RenderHelper; /** * Class CompositionPropertyDecorator @@ -107,14 +108,13 @@ public function getBranchDeclaredPropertyNamesPhpLiteral(): string /** * Returns the patternProperties regexes as a PHP array literal ready for direct template - * embedding. var_export handles all string escaping so generated code does not need to - * round-trip patterns through base64 at runtime. + * embedding. Each pattern is wrapped with `/` delimiters and any embedded `/` is escaped so + * the result can be passed straight to `preg_match`. */ public function getBranchPatternPropertyPatternsPhpLiteral(): string { - return var_export( + return RenderHelper::varExportPcrePatterns( array_keys($this->jsonSchema->getJson()['patternProperties'] ?? []), - true, ); } diff --git a/src/Model/Validator/AbstractUnevaluatedPropertiesValidator.php b/src/Model/Validator/AbstractUnevaluatedPropertiesValidator.php new file mode 100644 index 00000000..9e84330b --- /dev/null +++ b/src/Model/Validator/AbstractUnevaluatedPropertiesValidator.php @@ -0,0 +1,70 @@ +`-form (`UnevaluatedPropertiesValidator`). + * + * Both emit a template that calls `$this->collectUnevaluatedKeys(...)` from + * `CompositionEvaluationTrait` with the same three argument shapes: the local declared property + * names, the local PCRE-ready patternProperties, and the composition-validator key list. The + * last is resolved at render time because post-composition validators register on the schema + * before `ComposedValueProcessor` finishes; subclasses cannot know the final list at + * construction time. + */ +abstract class AbstractUnevaluatedPropertiesValidator extends PropertyTemplateValidator +{ + /** + * @param array $extraTemplateValues Subclass-specific template values merged + * on top of the shared set. + */ + public function __construct( + protected readonly Schema $compositionScope, + JsonSchema $propertiesStructure, + string $templatePath, + string $exceptionClass, + array $exceptionParams, + array $extraTemplateValues = [], + ?string $propertyName = null, + ) { + $json = $propertiesStructure->getJson(); + + parent::__construct( + new Property($propertyName ?? $compositionScope->getClassName(), null, $propertiesStructure), + $templatePath, + $extraTemplateValues + [ + 'declaredPropertyNames' => RenderHelper::varExportArray( + array_keys($json['properties'] ?? []), + ), + 'pcrePatterns' => RenderHelper::varExportPcrePatterns( + array_keys($json['patternProperties'] ?? []), + ), + ], + $exceptionClass, + $exceptionParams, + ); + } + + /** + * @inheritDoc + */ + public function getCheck(): string + { + // Resolved here — not in the constructor — because post-composition validators register + // on the schema after this validator's constructor returns. By the time getCheck() runs + // the base-validator list is final. + $this->templateValues['compositionValidatorKeys'] = RenderHelper::varExportArray( + $this->compositionScope->getCompositionValidatorKeys(), + ); + + return parent::getCheck(); + } +} diff --git a/src/Model/Validator/AdditionalPropertiesValidator.php b/src/Model/Validator/AdditionalPropertiesValidator.php index ca0b18fa..dae41b1d 100644 --- a/src/Model/Validator/AdditionalPropertiesValidator.php +++ b/src/Model/Validator/AdditionalPropertiesValidator.php @@ -67,7 +67,9 @@ public function __construct( 'additionalProperties' => RenderHelper::varExportArray( array_keys($propertiesStructure->getJson()[static::PROPERTIES_KEY] ?? []), ), - 'patternProperties' => $patternProperties ? RenderHelper::varExportArray($patternProperties) : null, + 'patternProperties' => $patternProperties + ? RenderHelper::varExportPcrePatterns($patternProperties) + : null, 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), // by default don't collect additional property data diff --git a/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php new file mode 100644 index 00000000..e68bf9e5 --- /dev/null +++ b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php @@ -0,0 +1,56 @@ +getJson(); + + // `unevaluatedProperties: true` is the spec default — every unevaluated key is allowed, + // so no validator is needed. Absent keyword is treated the same way. + if (!isset($json[$this->key]) || $json[$this->key] === true) { + return; + } + + // When the same schema declares a non-false `additionalProperties`, every key not + // claimed by `properties`/`patternProperties` is already evaluated by it. Composition + // contributions cannot retract that — `additionalProperties` only succeeds when every + // matched key passes — so the unevaluated set is guaranteed empty and the validator + // would be a no-op. Skip emission entirely. + if (isset($json['additionalProperties']) && $json['additionalProperties'] !== false) { + return; + } + + if ($json[$this->key] === false) { + $schema->addPostCompositionValidator( + new NoUnevaluatedPropertiesValidator($schema, $propertySchema), + ); + + return; + } + + $schema->addPostCompositionValidator( + new UnevaluatedPropertiesValidator($schemaProcessor, $schema, $propertySchema), + ); + } +} diff --git a/src/Model/Validator/NoUnevaluatedPropertiesValidator.php b/src/Model/Validator/NoUnevaluatedPropertiesValidator.php new file mode 100644 index 00000000..9b48d7de --- /dev/null +++ b/src/Model/Validator/NoUnevaluatedPropertiesValidator.php @@ -0,0 +1,31 @@ +isResolved = true; + + parent::__construct( + $compositionScope, + $propertiesStructure, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'NoUnevaluatedProperties.phptpl', + UnevaluatedPropertiesException::class, + ['&$unevaluatedProperties'], + ); + } +} diff --git a/src/Model/Validator/UnevaluatedPropertiesValidator.php b/src/Model/Validator/UnevaluatedPropertiesValidator.php new file mode 100644 index 00000000..a48383d0 --- /dev/null +++ b/src/Model/Validator/UnevaluatedPropertiesValidator.php @@ -0,0 +1,86 @@ +`. + * + * Mirrors AdditionalPropertiesValidator, except the iterated key set is the model keys + * left over after subtracting the evaluated set (local properties/patternProperties plus the + * contributions of sibling composition branches). + */ +class UnevaluatedPropertiesValidator extends AbstractUnevaluatedPropertiesValidator +{ + protected const PROPERTY_NAME = 'unevaluated property'; + + private readonly PropertyInterface $validationProperty; + + /** + * @throws SchemaException + */ + public function __construct( + SchemaProcessor $schemaProcessor, + Schema $compositionScope, + JsonSchema $propertiesStructure, + ?string $propertyName = null, + ) { + $this->validationProperty = (new PropertyFactory())->create( + $schemaProcessor, + $compositionScope, + static::PROPERTY_NAME, + $propertiesStructure->navigate('unevaluatedProperties'), + true, + ); + + $this->validationProperty->onResolve(function (): void { + $this->resolve(); + }); + + parent::__construct( + $compositionScope, + $propertiesStructure, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'UnevaluatedProperties.phptpl', + InvalidUnevaluatedPropertiesException::class, + ['&$invalidProperties'], + [ + 'schema' => $compositionScope, + 'validationProperty' => $this->validationProperty, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + ], + $propertyName, + ); + } + + /** + * @inheritDoc + */ + public function getCheck(): string + { + $this->removeRequiredPropertyValidator($this->validationProperty); + + return parent::getCheck(); + } + + /** + * Initialize all variables which are required to execute an unevaluated properties validator. + */ + public function getValidatorSetUp(): string + { + return ' + $properties = $value; + $invalidProperties = []; + '; + } +} diff --git a/src/SchemaProcessor/PostProcessor/Internal/AdditionalPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/AdditionalPropertiesPostProcessor.php index 373a3ad4..6b14460e 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/AdditionalPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/AdditionalPropertiesPostProcessor.php @@ -101,7 +101,7 @@ public function __construct(Schema $schema) ), [ 'patternProperties' => $patternProperties - ? RenderHelper::varExportArray($patternProperties) + ? RenderHelper::varExportPcrePatterns($patternProperties) : null, 'additionalProperties' => RenderHelper::varExportArray( array_keys($schema->getJsonSchema()->getJson()['properties'] ?? []), diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php index 12496687..737c5da3 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -34,18 +34,21 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu return; } - foreach ($schema->getBaseValidators() as $baseValidator) { - if ($baseValidator instanceof AbstractComposedPropertyValidator) { - $baseValidator->enableEvaluationTracking(); - } - } - - if (empty($schema->getCompositionValidatorKeys())) { - return; - } + // The nested branch schemas may already be queued — adding the method here, before any + // render() runs, ensures every branch class carries getEvaluatedProperties() regardless + // of whether the outer or inner schema was processed first. RenderQueue::execute runs + // process() over every job before render() begins. + $this->addGetEvaluatedPropertiesToNestedBranchSchemas($schema); + // The trait carries collectUnevaluatedKeys(), which every generated unevaluatedProperties + // validator calls regardless of whether the schema also has composition validators. It + // must therefore be attached on every activation-triggering schema, not only on ones that + // declare allOf/anyOf/oneOf/if-then-else. $schema->addTrait(CompositionEvaluationTrait::class); + // The cache field is only written when there are composition validators, but the trait + // reads $this->_compositionEvaluations even on no-composition schemas. Always declare the + // property so the read never touches an undefined property (deprecated in PHP 8.2+). $schema->addProperty( (new Property( 'compositionEvaluations', @@ -56,7 +59,11 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu ->setDefaultValue([]), ); - $this->addGetEvaluatedPropertiesToNestedBranchSchemas($schema); + foreach ($schema->getBaseValidators() as $baseValidator) { + if ($baseValidator instanceof AbstractComposedPropertyValidator) { + $baseValidator->enableEvaluationTracking(); + } + } } /** diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/UpdateAdditionalProperties.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/UpdateAdditionalProperties.phptpl index 808a28b1..6a836324 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/UpdateAdditionalProperties.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/UpdateAdditionalProperties.phptpl @@ -2,7 +2,7 @@ foreach (array_diff(array_keys($value), {{ additionalProperties }}) as $propertyKey) { {% if patternProperties %} foreach ({{ patternProperties }} as $pattern) { - if (preg_match("/$pattern/", $propertyKey)) { + if (preg_match($pattern, $propertyKey)) { continue 2; } } diff --git a/src/SchemaProcessor/RenderQueue.php b/src/SchemaProcessor/RenderQueue.php index af365193..8a90c37d 100644 --- a/src/SchemaProcessor/RenderQueue.php +++ b/src/SchemaProcessor/RenderQueue.php @@ -44,8 +44,16 @@ public function execute(GeneratorConfiguration $generatorConfiguration, array $p $postProcessor->preProcess(); } + // Decouple post-processing from rendering so that a post processor on schema A may + // mutate schema B (e.g. attach a method to a nested composition-branch class) and have + // those mutations visible when B is rendered, regardless of queue order. Without this + // separation, a process()/render() loop interleaved per schema would render B before + // A's process() had a chance to touch it. foreach ($this->jobs as $job) { $job->executePostProcessors($postProcessors, $generatorConfiguration); + } + + foreach ($this->jobs as $job) { $job->render($generatorConfiguration); } diff --git a/src/Templates/Validator/AdditionalProperties.phptpl b/src/Templates/Validator/AdditionalProperties.phptpl index 96c93e64..f3326bf3 100644 --- a/src/Templates/Validator/AdditionalProperties.phptpl +++ b/src/Templates/Validator/AdditionalProperties.phptpl @@ -9,7 +9,7 @@ foreach (array_diff(array_keys($properties), {{ additionalProperties }}) as $propertyKey) { {% if patternProperties %} foreach ({{ patternProperties }} as $pattern) { - if (preg_match("/$pattern/", $property)) { + if (preg_match($pattern, $property)) { continue 2; } } diff --git a/src/Templates/Validator/NoUnevaluatedProperties.phptpl b/src/Templates/Validator/NoUnevaluatedProperties.phptpl new file mode 100644 index 00000000..52865a2e --- /dev/null +++ b/src/Templates/Validator/NoUnevaluatedProperties.phptpl @@ -0,0 +1,6 @@ +$unevaluatedProperties = $this->collectUnevaluatedKeys( + $modelData, + {{ declaredPropertyNames }}, + {{ pcrePatterns }}, + {{ compositionValidatorKeys }}, +) diff --git a/src/Templates/Validator/UnevaluatedProperties.phptpl b/src/Templates/Validator/UnevaluatedProperties.phptpl new file mode 100644 index 00000000..73a7ade2 --- /dev/null +++ b/src/Templates/Validator/UnevaluatedProperties.phptpl @@ -0,0 +1,44 @@ +(function () use ($properties, &$invalidProperties, $modelData): bool { + {% if generatorConfiguration.collectErrors() %} + $originalErrorRegistry = $this->_errorRegistry; + {% endif %} + + $unevaluatedKeys = $this->collectUnevaluatedKeys( + $modelData, + {{ declaredPropertyNames }}, + {{ pcrePatterns }}, + {{ compositionValidatorKeys }}, + ); + + foreach ($unevaluatedKeys as $propertyKey) { + try { + $value = $properties[$propertyKey]; + + {% if generatorConfiguration.collectErrors() %} + $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% endif %} + + {{ viewHelper.resolvePropertyDecorator(validationProperty) }} + + {% foreach validationProperty.getOrderedValidators() as validator %} + {{ viewHelper.renderValidator(validator, schema) }} + {% endforeach %} + + {% if generatorConfiguration.collectErrors() %} + if ($this->_errorRegistry->getErrors()) { + $invalidProperties[$propertyKey] = $this->_errorRegistry->getErrors(); + } + {% endif %} + } catch (\Exception $e) { + isset($invalidProperties[$propertyKey]) + ? $invalidProperties[$propertyKey][] = $e + : $invalidProperties[$propertyKey] = [$e]; + } + } + + {% if generatorConfiguration.collectErrors() %} + $this->_errorRegistry = $originalErrorRegistry; + {% endif %} + + return !empty($invalidProperties); +})() diff --git a/src/Utils/RenderHelper.php b/src/Utils/RenderHelper.php index 76935cff..4d3e979a 100644 --- a/src/Utils/RenderHelper.php +++ b/src/Utils/RenderHelper.php @@ -187,6 +187,23 @@ public static function varExportArray(array $values): string return preg_replace('(\d+\s=>)', '', var_export($values, true)); } + /** + * Returns a PHP array literal whose values are each raw $patterns entry wrapped with `/` + * delimiters and with any embedded `/` characters escaped so they cannot terminate the + * delimiter early. The result is ready for direct `preg_match` use at runtime. + * + * @param string[] $rawPatterns Raw patternProperties regexes from a JSON Schema. + */ + public static function varExportPcrePatterns(array $rawPatterns): string + { + return self::varExportArray( + array_map( + static fn(string $rawPattern): string => '/' . addcslashes($rawPattern, '/') . '/', + $rawPatterns, + ), + ); + } + public static function filterClassImports(array $imports, string $namespace): array { // filter out non-compound uses and uses which link to the current namespace diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php new file mode 100644 index 00000000..01d76263 --- /dev/null +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -0,0 +1,185 @@ +}> + */ + public static function acceptanceProvider(): array + { + return [ + 'declared property only' => [ + 'NoExtraPropertiesAllowed.json', + ['name' => 'Alice'], + ], + 'empty input when no required properties' => [ + 'NoExtraPropertiesAllowed.json', + [], + ], + 'pattern-matched property accepted' => [ + 'PatternPropertiesEvaluated.json', + ['x_one' => 'a', 'x_two' => 'b'], + ], + 'unevaluatedProperties schema accepts matching extra' => [ + 'SchemaFormUnevaluated.json', + ['name' => 'Alice', 'count' => 42], + ], + 'allOf branches contribute evaluated keys to outer' => [ + 'AllOfBranchesCoverEvaluation.json', + ['name' => 'Alice', 'foo' => 'hello', 'bar' => 42], + ], + 'anyOf first-branch success contributes kind' => [ + 'AnyOfSelectsSuccessfulBranches.json', + ['kind' => 'A'], + ], + 'anyOf second-branch success contributes value' => [ + 'AnyOfSelectsSuccessfulBranches.json', + ['value' => 99], + ], + 'if-branch pass contributes if + then names' => [ + 'IfThenElseEvaluation.json', + ['kind' => 'A', 'valueA' => 'hello'], + ], + 'if-branch fail contributes else name only' => [ + 'IfThenElseEvaluation.json', + ['kind' => 'B', 'valueB' => 7], + ], + 'not-branch success leaves outer declared property accessible' => [ + 'NotBranchContributesNothing.json', + ['foo' => 'hello'], + ], + ]; + } + + /** + * @param array $input + */ + #[DataProvider('acceptanceProvider')] + public function testAcceptedInputRoundTrips(string $schemaFile, array $input): void + { + $className = $this->generateClassFromFile($schemaFile); + $instance = new $className($input); + + $this->assertSame($input, $instance->getRawModelDataInput()); + } + + /** + * Rejected input — construction must throw the named exception with a message describing + * the offending key(s). The regex pattern is anchored on the full message so a different + * exception body cannot accidentally satisfy it. + * + * @return array, 2: class-string<\Throwable>, 3: string}> + */ + public static function rejectionProvider(): array + { + $notAllowed = static fn(string $propertyName): string => + '/^Provided JSON for .* contains not allowed unevaluated properties \[' + . preg_quote($propertyName, '/') + . '\]$/'; + + return [ + 'undeclared property at top level' => [ + 'NoExtraPropertiesAllowed.json', + ['name' => 'Alice', 'extra' => 'value'], + UnevaluatedPropertiesException::class, + $notAllowed('extra'), + ], + 'non-pattern property rejected' => [ + 'PatternPropertiesEvaluated.json', + ['x_ok' => 'a', 'other' => 'bad'], + UnevaluatedPropertiesException::class, + $notAllowed('other'), + ], + 'unevaluatedProperties schema rejects failing extra' => [ + 'SchemaFormUnevaluated.json', + ['name' => 'Alice', 'count' => 'not-an-integer'], + InvalidUnevaluatedPropertiesException::class, + '/^Provided JSON for .* contains invalid unevaluated properties/', + ], + 'allOf rejects key not claimed by any branch' => [ + 'AllOfBranchesCoverEvaluation.json', + ['name' => 'Alice', 'foo' => 'hello', 'stray' => 'unclaimed'], + UnevaluatedPropertiesException::class, + $notAllowed('stray'), + ], + 'if/then/else rejects key only claimed by opposite branch' => [ + // kind='A' triggers `if` → `then` (which declares valueA), not `else` (valueB). + // valueB is not claimed by any successful branch. + 'IfThenElseEvaluation.json', + ['kind' => 'A', 'valueB' => 7], + UnevaluatedPropertiesException::class, + $notAllowed('valueB'), + ], + 'not-branch success does not contribute forbidden to evaluated' => [ + // The fixture's `not` schema is { properties: { forbidden: integer }, + // required: [forbidden] }. A string `forbidden` makes the not-body fail + // (string ≠ integer), so `not` SUCCEEDS — meaning the not-branch's `forbidden` + // declaration is visible at slot-write time. If the implementation treated + // not-success as contributing annotations, this case would slip through. + 'NotBranchContributesNothing.json', + ['foo' => 'hello', 'forbidden' => 'string-value'], + UnevaluatedPropertiesException::class, + $notAllowed('forbidden'), + ], + ]; + } + + /** + * @param array $input + * @param class-string<\Throwable> $exceptionClass + */ + #[DataProvider('rejectionProvider')] + public function testRejectedInputThrows( + string $schemaFile, + array $input, + string $exceptionClass, + string $messageRegex, + ): void { + $this->expectException($exceptionClass); + $this->expectExceptionMessageMatches($messageRegex); + + $className = $this->generateClassFromFile($schemaFile); + new $className($input); + } + + /** + * When the same schema declares a non-false `additionalProperties`, `unevaluatedProperties` + * is a no-op — every key not in `properties`/`patternProperties` is already claimed by + * `additionalProperties`. The factory therefore skips emitting any unevaluatedProperties + * validator. This is a separate test rather than an acceptance row because the absence of + * a validator (not merely its acceptance) is what is being verified. + */ + public function testAdditionalPropertiesShortCircuitsUnevaluatedCheck(): void + { + $className = $this->generateClassFromFile('AdditionalPropertiesShortcut.json'); + + // `extra` is not in `properties`, but `additionalProperties: {type: string}` claims it. + // The unevaluatedProperties: false declaration must NOT reject it. + $instance = new $className(['name' => 'Alice', 'extra' => 'value']); + + $this->assertSame( + ['name' => 'Alice', 'extra' => 'value'], + $instance->getRawModelDataInput(), + ); + } +} diff --git a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/IfThenElseInlineBranches.json b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/IfThenElseInlineBranches.json index b5c9bfb0..33d63c1f 100644 --- a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/IfThenElseInlineBranches.json +++ b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/IfThenElseInlineBranches.json @@ -1,5 +1,10 @@ { "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, "if": { "properties": { "kind": { diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalPropertiesShortcut.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalPropertiesShortcut.json new file mode 100644 index 00000000..c46ad6c3 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalPropertiesShortcut.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AllOfBranchesCoverEvaluation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AllOfBranchesCoverEvaluation.json new file mode 100644 index 00000000..9d3ecb68 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AllOfBranchesCoverEvaluation.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "properties": { + "bar": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfSelectsSuccessfulBranches.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfSelectsSuccessfulBranches.json new file mode 100644 index 00000000..c30c06aa --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfSelectsSuccessfulBranches.json @@ -0,0 +1,26 @@ +{ + "type": "object", + "anyOf": [ + { + "properties": { + "kind": { + "type": "string" + } + }, + "required": [ + "kind" + ] + }, + { + "properties": { + "value": { + "type": "integer" + } + }, + "required": [ + "value" + ] + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenElseEvaluation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenElseEvaluation.json new file mode 100644 index 00000000..33d63c1f --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenElseEvaluation.json @@ -0,0 +1,33 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "if": { + "properties": { + "kind": { + "const": "A" + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "valueA": { + "type": "string" + } + } + }, + "else": { + "properties": { + "valueB": { + "type": "integer" + } + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/NoExtraPropertiesAllowed.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/NoExtraPropertiesAllowed.json new file mode 100644 index 00000000..59b98669 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/NoExtraPropertiesAllowed.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchContributesNothing.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchContributesNothing.json new file mode 100644 index 00000000..a991b5ff --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchContributesNothing.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "foo": { + "type": "string" + } + }, + "not": { + "properties": { + "forbidden": { + "type": "integer" + } + }, + "required": [ + "forbidden" + ] + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/PatternPropertiesEvaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/PatternPropertiesEvaluated.json new file mode 100644 index 00000000..5b8e94a2 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/PatternPropertiesEvaluated.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "patternProperties": { + "^x_": { + "type": "string" + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/SchemaFormUnevaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/SchemaFormUnevaluated.json new file mode 100644 index 00000000..33998863 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/SchemaFormUnevaluated.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "integer" + } +} From e43fbc9f4b9508a5b49a72bb20c3dc3ea6943572 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 4 Jun 2026 00:27:47 +0200 Subject: [PATCH 05/51] Fix items: false validator for 4-arg MaxItemsException signature The dev-jsonSchemaDraft2019 production library widened MaxItemsException to require an actual count alongside the maxItems limit. The items: false branch of ItemsValidatorFactory still passed only the maxItems literal (via [0]), producing a 3-arg call that broke against the 4-arg constructor. Mirror the working pattern from MaxItems / MinItems factories: define $count inside the validation expression and pass it as the fourth constructor argument by reference. Co-Authored-By: Claude Opus 4.7 --- .../Validator/Factory/Arrays/ItemsValidatorFactory.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php index bfd72e42..d9458a00 100644 --- a/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php +++ b/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php @@ -38,12 +38,16 @@ public function modify( if (is_bool($itemsSchema)) { if ($itemsSchema === false) { + // `items: false` means the array must be empty. MaxItemsException's constructor + // requires both the maxItems limit and the actual count of the offending array + // — define $count via the expression so the validator can pass it as the fourth + // constructor argument alongside the literal 0 maxItems. $property->addValidator( new PropertyValidator( $property, - 'count($value) > 0', + '($count = count($value)) > 0', MaxItemsException::class, - [0], + [0, '&$count'], ), ); } From cc2bef850398a63bec8ce2657d91995525112027 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 4 Jun 2026 17:54:54 +0200 Subject: [PATCH 06/51] Add unevaluatedProperties accessor and rollback registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces UnevaluatedPropertiesAccessorPostProcessor, an opt-in post processor that exposes the keys claimed by `unevaluatedProperties` via a single getter on the model: `$model->unevaluatedProperties()` returns either the bare production-library accessor (untyped schema) or a generated companion class `{ModelName}UnevaluatedProperties` with narrowed get/set/ getAll/remove signatures (typed schema). Private mutator shims `_setUnevaluatedProperty` / `_removeUnevaluatedProperty` host the runtime guard and per-call validation; the accessor's closures bind to them via first-class callable syntax. The shim guard rejects keys that match a directly-declared property name, a local `patternProperties` pattern, or any property name/pattern declared inside a composition branch (allOf / anyOf / oneOf / if / then / else, walked recursively) — those keys belong to a different contract and routing them through the unevaluated bucket would silently bypass the typed validator. Accessor emission honours the keyword's reachability at this schema level: when `additionalProperties: false` rejects every extra or `additionalProperties: {schema}` claims every extra, the unevaluated bucket is unreachable and nothing is emitted (no backing field, accessor method, shims, companion, or collect flag) — the validator continues to run as a pure assertion. UnevaluatedPropertiesValidator gains a `setCollectUnevaluatedProperties` flag (mirroring the additional-properties pattern) so the validator writes into `$this->_unevaluatedProperties` after per-key validation succeeds, with a rollback on whole-validator failure to preserve the prior bucket state. SerializableTrait in the production library learns to flatten `_unevaluatedProperties` back into `toArray()` / `toJSON()` output via a new `_serializeUnevaluatedProperties()` method. Populate.phptpl's hardcoded rollback / accessor-cache property lists are replaced with two registries on Schema (`addRollbackProperty`, `addAccessorCacheProperty`). Existing internal post processors register their fields into the registries; the new accessor registers `_unevaluatedProperties` and `_unevaluatedPropertiesAccessor`. The change is a no-op for any schema that does not opt into the unevaluated accessor. Test suite (UnevaluatedPropertiesAccessorPostProcessorTest, 15 tests, all Drafts 2019-09 and 2020-12): typed round-trip; constraint-violation rejection in direct-exception mode with rollback verification; runtime guards (declared property, composition-branch property); companion-class naming and method surface; immutable read-only variant; serialization round-trip; dead-code suppression; collect-errors-mode setter isolation (proves the per-call error-registry reset is load-bearing); coexistence matrix asserting the additionalProperties × unevaluatedProperties emission policy when both accessor post processors are configured simultaneously. CLAUDE.md gains an expanded patterns list and recovery procedure for the "no implementation-plan references in code" rule so that section numbers, decision identifiers, and similar artifacts of working notes can be caught before they land. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 37 +- src/Model/Schema.php | 52 +++ .../UnevaluatedPropertiesValidator.php | 13 + ...itionalPropertiesAccessorPostProcessor.php | 1 + .../AdditionalPropertiesPostProcessor.php | 1 + .../PatternPropertiesPostProcessor.php | 1 + ...PatternPropertiesAccessorPostProcessor.php | 1 + .../UnevaluatedPropertiesCompanion.phptpl | 62 +++ .../PostProcessor/Templates/Populate.phptpl | 14 +- .../RemoveUnevaluatedProperty.phptpl | 30 ++ .../SetUnevaluatedProperty.phptpl | 50 ++ ...UnevaluatedPropertiesAccessorMethod.phptpl | 10 + ...aluatedPropertiesAccessorPostProcessor.php | 434 ++++++++++++++++++ .../Validator/UnevaluatedProperties.phptpl | 13 + ...tedPropertiesAccessorPostProcessorTest.php | 413 +++++++++++++++++ .../AdditionalFalseWithUnevaluatedSchema.json | 12 + ...AdditionalSchemaWithUnevaluatedSchema.json | 14 + .../AdditionalTrueWithUnevaluatedSchema.json | 12 + .../AllOfBranchOwnsProperty.json | 15 + .../TypedExtras.json | 11 + .../TypedExtrasWithRange.json | 12 + .../UnevaluatedFalse.json | 9 + 22 files changed, 1204 insertions(+), 13 deletions(-) create mode 100644 src/SchemaProcessor/PostProcessor/Templates/Companion/UnevaluatedPropertiesCompanion.phptpl create mode 100644 src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl create mode 100644 src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl create mode 100644 src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/UnevaluatedPropertiesAccessorMethod.phptpl create mode 100644 src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php create mode 100644 tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalFalseWithUnevaluatedSchema.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalSchemaWithUnevaluatedSchema.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalTrueWithUnevaluatedSchema.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AllOfBranchOwnsProperty.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtras.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithRange.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UnevaluatedFalse.json diff --git a/CLAUDE.md b/CLAUDE.md index df8e8535..3201f054 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -356,13 +356,29 @@ fail (`@expectedExceptionMessage`, `$this->expectException(...)`) until the fix #### No implementation-plan references in code -Do not embed references to implementation-plan phases, issue numbers, or source-code line numbers -in comments, docblocks, filenames, or any other artifact that lands in the repository. These -references decay immediately (phases complete, line numbers shift) and add noise without adding -meaning. - +Do not embed references to implementation-plan phases, section numbers, decision identifiers, +issue numbers, or source-code line numbers in comments, docblocks, filenames, test fixture +descriptions, or any other artifact that lands in the repository. These references decay +immediately (plans get restructured, phases complete, sections renumber, line numbers shift) +and add noise without adding meaning to a reader who does not have the plan open in another +tab. + +**Patterns that violate the rule** — anything in this category must be rewritten: +- `Phase N`, `Phase N's`, `phase N landed` +- `decision N.N`, `Decision N.N`, `per decision N.N` +- `§N.N`, `§N.N.N`, `section N.N`, `§N.N's matrix` +- `Per §N.N`, `Follows §N.N`, `the §N.N test list` +- Issue numbers (`#123`) used as a stand-in for an explanation +- Specific line numbers in the codebase (`lines 130-158`, `line 429`) +- References to documents under `.claude/` from anywhere outside `.claude/` + +**Examples:** - ❌ `// Phase 2 guarantees anyOf/oneOf have uniform spaces` - ✅ `// Static rejection guarantees anyOf/oneOf have uniform spaces` +- ❌ `* Emission policy follows the §3.5.2.1 / decision 0.10 matrix` +- ✅ `* Emission policy: emit when the keyword's reach is non-empty (additionalProperties absent or true)` +- ❌ `// Dead-code rows from §4.1: additionalProperties: false or {schema}` +- ✅ `// additionalProperties: false / {schema} leave the unevaluated bucket permanently empty` - ❌ `* Covers FilterValidator::runCompatibilityCheck lines 130–158` - ✅ `* Validates the zero-overlap rejection path in FilterValidator` - ❌ `* exercises FilterProcessor line 429 (else branch of classifyValidatorAdjustments)` @@ -372,9 +388,14 @@ meaning. - ❌ `// Phase 3's UnevaluatedPropertiesValidator can query...` - ❌ `// not with inline branch — Decision 0.6: slot permanently success=false` -This rule applies equally to DocBlocks in test files: do not reference specific line numbers of -the code under test. Line numbers shift whenever the file is edited, making such references -misleading immediately after refactoring. Describe *what the code does or why* instead. +This rule applies equally to DocBlocks in test files: do not reference specific line numbers +of the code under test, decision identifiers from the plan, or section numbers anywhere in the +plan. Line numbers shift whenever the file is edited, and section/decision numbers decay +whenever the plan is restructured. Describe *what the code does or why* instead. + +**Recovery procedure when this rule is violated:** before staging a change, grep the diff for +`Phase `, `decision `, `§`, and `#` followed by a number. Rewrite every match found in source +or test files to a self-contained explanation of the rule or behaviour. Describe *what the code does or why* — not where it came from in a planning document. diff --git a/src/Model/Schema.php b/src/Model/Schema.php index eca6a106..a884c0b3 100644 --- a/src/Model/Schema.php +++ b/src/Model/Schema.php @@ -71,6 +71,20 @@ class Schema /** @var string[] Maps normalized attribute → raw property name; used to detect property-vs-property collisions */ private array $attributeIndex = []; + /** + * @var array Internal model fields whose pre-populate value must be restored + * if populate() rolls back. Post processors register their backing + * collection fields here; Populate.phptpl iterates the list. + */ + private array $rollbackProperties = []; + + /** + * @var array Internal accessor-cache fields that must be cleared before + * populate() runs so a stale cached accessor instance isn't reused + * after the underlying storage changes. + */ + private array $accessorCacheProperties = []; + private PropertyMerger $propertyMerger; /** @@ -409,6 +423,44 @@ public function isInitialClass(): bool return $this->initialClass; } + /** + * Register an internal model field whose pre-populate value must be restored on rollback. + * Idempotent — registering the same field twice is a no-op. Order of registration is + * preserved by returning array_keys; the order is the order rollback is iterated. + */ + public function addRollbackProperty(string $internalPropertyName): self + { + $this->rollbackProperties[$internalPropertyName] = true; + + return $this; + } + + /** + * @return string[] + */ + public function getRollbackProperties(): array + { + return array_keys($this->rollbackProperties); + } + + /** + * Register an internal accessor-cache field that must be reset before populate() runs. + */ + public function addAccessorCacheProperty(string $internalPropertyName): self + { + $this->accessorCacheProperties[$internalPropertyName] = true; + + return $this; + } + + /** + * @return string[] + */ + public function getAccessorCacheProperties(): array + { + return array_keys($this->accessorCacheProperties); + } + public function getPropertyMerger(): PropertyMerger { return $this->propertyMerger; diff --git a/src/Model/Validator/UnevaluatedPropertiesValidator.php b/src/Model/Validator/UnevaluatedPropertiesValidator.php index a48383d0..6c200964 100644 --- a/src/Model/Validator/UnevaluatedPropertiesValidator.php +++ b/src/Model/Validator/UnevaluatedPropertiesValidator.php @@ -25,6 +25,7 @@ class UnevaluatedPropertiesValidator extends AbstractUnevaluatedPropertiesValida protected const PROPERTY_NAME = 'unevaluated property'; private readonly PropertyInterface $validationProperty; + private bool $collectUnevaluatedProperties = false; /** * @throws SchemaException @@ -58,6 +59,8 @@ public function __construct( 'validationProperty' => $this->validationProperty, 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + // by default the unevaluated keys validate but are not collected on the model + 'collectUnevaluatedProperties' => &$this->collectUnevaluatedProperties, ], $propertyName, ); @@ -73,6 +76,16 @@ public function getCheck(): string return parent::getCheck(); } + public function setCollectUnevaluatedProperties(bool $collectUnevaluatedProperties): void + { + $this->collectUnevaluatedProperties = $collectUnevaluatedProperties; + } + + public function getValidationProperty(): PropertyInterface + { + return $this->validationProperty; + } + /** * Initialize all variables which are required to execute an unevaluated properties validator. */ diff --git a/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php b/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php index 1314a324..2c1ea789 100644 --- a/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php @@ -86,6 +86,7 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu ->setDefaultValue('null', true) ->setInternal(true), ); + $schema->addAccessorCacheProperty('_additionalPropertiesAccessor'); $this->addAccessorMethod($schema, $generatorConfiguration, $hasCompanion); diff --git a/src/SchemaProcessor/PostProcessor/Internal/AdditionalPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/AdditionalPropertiesPostProcessor.php index 6b14460e..fb4eb3a1 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/AdditionalPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/AdditionalPropertiesPostProcessor.php @@ -65,6 +65,7 @@ public function addAdditionalPropertiesCollectionProperty(Schema $schema): void } $schema->addProperty($additionalPropertiesCollectionProperty); + $schema->addRollbackProperty('_additionalProperties'); $json = $schema->getJsonSchema()->getJson(); if (!isset($json['additionalProperties']) || $json['additionalProperties'] === true) { diff --git a/src/SchemaProcessor/PostProcessor/Internal/PatternPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/PatternPropertiesPostProcessor.php index 8a7fd56d..baa5e2f7 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/PatternPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/PatternPropertiesPostProcessor.php @@ -94,6 +94,7 @@ private function addPatternPropertiesCollectionProperty( ->setDefaultValue(array_fill_keys($patternHashes, [])) ->setInternal(true), ); + $schema->addRollbackProperty('_patternProperties'); } /** diff --git a/src/SchemaProcessor/PostProcessor/PatternPropertiesAccessorPostProcessor.php b/src/SchemaProcessor/PostProcessor/PatternPropertiesAccessorPostProcessor.php index 8f3aab69..67baf278 100644 --- a/src/SchemaProcessor/PostProcessor/PatternPropertiesAccessorPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/PatternPropertiesAccessorPostProcessor.php @@ -53,6 +53,7 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu ->setDefaultValue('null', true) ->setInternal(true), ); + $schema->addAccessorCacheProperty('_patternPropertiesAccessor'); $this->addAccessorMethod($schema, $generatorConfiguration, $hasCompanion); diff --git a/src/SchemaProcessor/PostProcessor/Templates/Companion/UnevaluatedPropertiesCompanion.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Companion/UnevaluatedPropertiesCompanion.phptpl new file mode 100644 index 00000000..6628ce28 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/Companion/UnevaluatedPropertiesCompanion.phptpl @@ -0,0 +1,62 @@ +unevaluatedProperties[$key] ?? null; + } + + /** + * @return {{ getAllReturnAnnotation }} + */ + public function getAll(): array + { + return $this->unevaluatedProperties; + } + + {% if not immutable %} + /** + * @param string $key + * @param {{ setParameterAnnotation }} $value + */ + public function set(string $key, {{ setParameterType }} $value): static + { + ($this->setter)($key, $value); + return $this; + } + + public function remove(string $key): bool + { + return ($this->remover)($key); + } + {% endif %} +} + +// @codeCoverageIgnoreEnd diff --git a/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl index e1053809..fe2cb41e 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl @@ -11,10 +11,12 @@ public function populate(array $modelData): self { // Reset accessor caches so any call after this populate reflects the final model state, - // whether populate succeeds or rolls back. - foreach (['_additionalPropertiesAccessor', '_patternPropertiesAccessor'] as $_accessorProperty) { - if (property_exists($this, $_accessorProperty)) { - $this->{$_accessorProperty} = null; + // whether populate succeeds or rolls back. The list is owned by Schema and populated by + // each accessor post processor that emits a cached accessor instance — see + // Schema::addAccessorCacheProperty(). + foreach ({{ viewHelper.varExportArray(schema.getAccessorCacheProperties()) }} as $accessorCacheField) { + if (property_exists($this, $accessorCacheField)) { + $this->{$accessorCacheField} = null; } } @@ -26,7 +28,9 @@ public function populate(array $modelData): self try { {% endif %} - foreach (['_additionalProperties', '_patternProperties'] as $property) { + // Each internal post processor that owns a backing collection field registers it here so + // its pre-populate value is restored on rollback — see Schema::addRollbackProperty(). + foreach ({{ viewHelper.varExportArray(schema.getRollbackProperties()) }} as $property) { if (isset($this->{$property})) { $rollbackValues[$property] = $this->{$property}; } diff --git a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl new file mode 100644 index 00000000..18c395eb --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl @@ -0,0 +1,30 @@ +/** + * {% if minPropertyValidator %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} + */ +private function _removeUnevaluatedProperty(string $key): bool +{ + if (!array_key_exists($key, $this->_unevaluatedProperties)) { + return false; + } + + {% if minPropertyValidator %} + {% if generatorConfiguration.collectErrors() %} + $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% endif %} + + if ({{ minPropertyValidator.getCheck() }}) { + {{ viewHelper.validationError(minPropertyValidator) }} + } + + {% if generatorConfiguration.collectErrors() %} + if (count($this->_errorRegistry->getErrors())) { + throw $this->_errorRegistry; + } + {% endif %} + {% endif %} + + unset($this->_unevaluatedProperties[$key]); + unset($this->_rawModelDataInput[$key]); + + return true; +} diff --git a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl new file mode 100644 index 00000000..ac1a05a3 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl @@ -0,0 +1,50 @@ +/** + * {% if hasObjectProperties or hasPatternProperties %}@throws RegularPropertyAsUnevaluatedPropertyException + * {% endif %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %} + */ +private function _setUnevaluatedProperty( + string $key, + {% if validationProperty %}{{ viewHelper.getType(validationProperty) }}{% else %}mixed{% endif %} $value +): void { + {% if hasObjectProperties %} + if (in_array($key, {{ objectProperties }})) { + throw new RegularPropertyAsUnevaluatedPropertyException($value, $key, self::class); + } + {% endif %} + + {% if hasPatternProperties %} + foreach ({{ patternProperties }} as $pattern) { + if (preg_match($pattern, $key)) { + throw new RegularPropertyAsUnevaluatedPropertyException($value, $key, self::class); + } + } + {% endif %} + + if (isset($this->_unevaluatedProperties[$key]) && $this->_unevaluatedProperties[$key] === $value) { + return; + } + + {% if validationProperty %}{{ schemaHookResolver.resolveSetterBeforeValidationHook(validationProperty) }}{% endif %} + + // _executeBaseValidators runs the unevaluatedProperties validator against the candidate + // pair, which writes the value into $this->_unevaluatedProperties on success. In + // collect-errors mode each call needs an isolated error registry — without resetting it, + // a previous setter that failed and was caught by the caller would leave stale errors that + // make every subsequent setter throw spuriously when its own validation actually succeeded. + {% if generatorConfiguration.collectErrors() %} + $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% endif %} + + $addedProperty = [$key => $value]; + $this->_executeBaseValidators($addedProperty); + + {% if generatorConfiguration.collectErrors() %} + if (count($this->_errorRegistry->getErrors())) { + throw $this->_errorRegistry; + } + {% endif %} + + $this->_rawModelDataInput[$key] = $value; + + {% if validationProperty %}{{ schemaHookResolver.resolveSetterAfterValidationHook(validationProperty) }}{% endif %} +} diff --git a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/UnevaluatedPropertiesAccessorMethod.phptpl b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/UnevaluatedPropertiesAccessorMethod.phptpl new file mode 100644 index 00000000..c5f6f669 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/UnevaluatedPropertiesAccessorMethod.phptpl @@ -0,0 +1,10 @@ +public function unevaluatedProperties(): {{ accessorType }} +{ + return $this->_unevaluatedPropertiesAccessor ??= new {{ accessorType }}( + $this->_unevaluatedProperties, + {% if not immutable %} + $this->_setUnevaluatedProperty(...), + $this->_removeUnevaluatedProperty(...), + {% endif %} + ); +} diff --git a/src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php b/src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php new file mode 100644 index 00000000..590b2b66 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php @@ -0,0 +1,434 @@ +` where `additionalProperties` does not already claim + * every extra key at the same level. + * + * Mirrors AdditionalPropertiesAccessorPostProcessor: emits a public getter that returns a + * cached accessor instance (either the bare production-library class for untyped schemas, or a + * generated companion class with narrowed types for typed schemas), plus private mutator shims + * `_setUnevaluatedProperty` / `_removeUnevaluatedProperty` invoked by the accessor's closures. + * + * Emission policy: + * - additionalProperties absent (denyAdditionalProperties off) / additionalProperties: true → emit + * - additionalProperties: false → skip + * - additionalProperties: {schema} → skip + * In the skip cases, additionalProperties already either rejects every extra key or claims and + * validates it, so _unevaluatedProperties would always be empty — exposing an accessor would + * be misleading. Skipping is total: no backing field, no accessor method, no shims, no + * companion, no setCollectUnevaluatedProperties(true) call. The validator continues to run as + * a pure assertion. + */ +class UnevaluatedPropertiesAccessorPostProcessor extends PostProcessor +{ + use CompanionGeneratorTrait; + + /** + * Add the unevaluatedProperties() accessor method to the provided schema. + * + * @throws SchemaException + */ + public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void + { + if (!$this->shouldEmitAccessor($schema, $generatorConfiguration)) { + return; + } + + $validator = $this->locateUnevaluatedValidator($schema); + if ($validator === null) { + return; + } + + $validator->setCollectUnevaluatedProperties(true); + $validationProperty = $validator->getValidationProperty(); + + $this->addBackingField($schema, $validationProperty); + $this->addAccessorCacheField($schema); + + $hasCompanion = $validationProperty->getType() !== null; + $isImmutable = $generatorConfiguration->isImmutable(); + + $this->addAccessorMethod($schema, $generatorConfiguration, $hasCompanion); + + if (!$isImmutable) { + $this->addSetUnevaluatedPropertyMethod($schema, $generatorConfiguration, $validationProperty); + $this->addRemoveUnevaluatedPropertyMethod($schema, $generatorConfiguration); + } + + if ($hasCompanion) { + $this->pendingCompanions[] = [ + 'schema' => $schema, + 'generatorConfiguration' => $generatorConfiguration, + 'validationProperty' => $validationProperty, + ]; + } + } + + /** + * Render the accessor iff unevaluatedProperties is declared as a non-false value AND + * additionalProperties at the same level does not already claim every extra. + */ + private function shouldEmitAccessor(Schema $schema, GeneratorConfiguration $generatorConfiguration): bool + { + $json = $schema->getJsonSchema()->getJson(); + + // unevaluatedProperties: false produces a NoUnevaluatedPropertiesValidator with no + // backing field — there is nothing to expose. + if (!array_key_exists('unevaluatedProperties', $json) || $json['unevaluatedProperties'] === false) { + return false; + } + + // additionalProperties: false / {schema} both leave _unevaluatedProperties permanently + // empty (the first rejects every extra, the second validates and claims every extra) — + // exposing an accessor with no possible contents would be misleading. + if (array_key_exists('additionalProperties', $json) && $json['additionalProperties'] !== true) { + return false; + } + + // denyAdditionalProperties() flips a missing additionalProperties to false → still dead. + if (!array_key_exists('additionalProperties', $json) && $generatorConfiguration->denyAdditionalProperties()) { + return false; + } + + return true; + } + + /** + * The factory attaches the UnevaluatedPropertiesValidator via + * Schema::addPostCompositionValidator() — not addBaseValidator() — because spec ordering + * requires the unevaluated check to run after every adjacent composition has had a chance + * to claim keys. The accessor post processor must look in the same bucket. + */ + private function locateUnevaluatedValidator(Schema $schema): ?UnevaluatedPropertiesValidator + { + foreach ($schema->getPostCompositionValidators() as $validator) { + if ($validator instanceof UnevaluatedPropertiesValidator) { + return $validator; + } + } + + return null; + } + + /** + * Recursively walks every composition branch on the schema and harvests the property + * names and pattern regexes those branches declare. A key matching either set must not be + * routed through the unevaluated accessor because it belongs to a composition contract + * (a typed inline branch member or a pattern-matched branch property), not to the + * unevaluated bucket. + * + * Walks composition validators registered on the schema's base validators and recurses + * through nested allOf/anyOf/oneOf/if-then-else by inspecting each branch's raw JSON. + * + * @return array{0: string[], 1: string[]} [propertyNames, patternRegexes] + */ + private function harvestCompositionPropertyNames(Schema $schema): array + { + $propertyNames = []; + $patternRegexes = []; + + foreach ($schema->getBaseValidators() as $baseValidator) { + if ($baseValidator instanceof AbstractComposedPropertyValidator) { + foreach ($baseValidator->getComposedProperties() as $branch) { + $this->collectBranchPropertyNames( + $branch->getBranchSchema()->getJson(), + $propertyNames, + $patternRegexes, + ); + } + } + } + + return [array_values(array_unique($propertyNames)), array_values(array_unique($patternRegexes))]; + } + + /** + * Walks a single branch's raw JSON schema and appends its `properties` keys and + * `patternProperties` regexes to the accumulators. Recurses into nested composition + * keywords because a branch may itself contain allOf/anyOf/oneOf whose sub-branches + * declare further properties. + * + * @param string[] $propertyNames accumulator for collected property names + * @param string[] $patternRegexes accumulator for collected pattern regexes + */ + private function collectBranchPropertyNames(array $branchJson, array &$propertyNames, array &$patternRegexes): void + { + foreach (array_keys($branchJson['properties'] ?? []) as $name) { + $propertyNames[] = (string) $name; + } + + foreach (array_keys($branchJson['patternProperties'] ?? []) as $pattern) { + $patternRegexes[] = (string) $pattern; + } + + foreach (['allOf', 'anyOf', 'oneOf'] as $compositionKey) { + foreach ($branchJson[$compositionKey] ?? [] as $nestedBranchJson) { + if (is_array($nestedBranchJson)) { + $this->collectBranchPropertyNames($nestedBranchJson, $propertyNames, $patternRegexes); + } + } + } + + foreach (['if', 'then', 'else'] as $conditionalKey) { + if (isset($branchJson[$conditionalKey]) && is_array($branchJson[$conditionalKey])) { + $this->collectBranchPropertyNames($branchJson[$conditionalKey], $propertyNames, $patternRegexes); + } + } + } + + /** + * @throws SchemaException + */ + private function addBackingField(Schema $schema, PropertyInterface $validationProperty): void + { + $backingField = (new Property( + 'unevaluatedProperties', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + 'Collect all unevaluated properties provided to the schema', + )) + ->setDefaultValue([]) + ->setInternal(true); + + if ($validationProperty->getType()) { + $backingField->addTypeHintDecorator(new ArrayTypeHintDecorator($validationProperty)); + } + + $schema->addProperty($backingField); + $schema->addRollbackProperty('_unevaluatedProperties'); + } + + /** + * @throws SchemaException + */ + private function addAccessorCacheField(Schema $schema): void + { + $schema->addProperty( + (new Property( + 'unevaluatedPropertiesAccessor', + null, + $schema->getJsonSchema(), + 'Cached accessor instance for unevaluated properties', + )) + ->setDefaultValue('null', true) + ->setInternal(true), + ); + $schema->addAccessorCacheProperty('_unevaluatedPropertiesAccessor'); + } + + private function addAccessorMethod( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + bool $hasCompanion, + ): void { + $isImmutable = $generatorConfiguration->isImmutable(); + + if (!$hasCompanion) { + $schema->addUsedClass($isImmutable + ? ImmutableUnevaluatedPropertiesAccessor::class + : UnevaluatedPropertiesAccessor::class); + } + + $accessorType = $hasCompanion + ? $schema->getClassName() . 'UnevaluatedProperties' + : (new ReflectionClass($isImmutable + ? ImmutableUnevaluatedPropertiesAccessor::class + : UnevaluatedPropertiesAccessor::class))->getShortName(); + + $schema->addMethod( + 'unevaluatedProperties', + new RenderedMethod( + $schema, + $generatorConfiguration, + 'UnevaluatedProperties/UnevaluatedPropertiesAccessorMethod.phptpl', + [ + 'accessorType' => $accessorType, + 'immutable' => $isImmutable, + ], + ), + ); + } + + private function addSetUnevaluatedPropertyMethod( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + PropertyInterface $validationProperty, + ): void { + $directProperties = array_map( + static fn(PropertyInterface $property): string => $property->getName(), + array_filter( + $schema->getProperties(), + static fn(PropertyInterface $property): bool => !$property->isInternal(), + ), + ); + + $directPatterns = array_keys($schema->getJsonSchema()->getJson()['patternProperties'] ?? []); + + // A composition branch's `properties` / `patternProperties` declarations contribute keys + // to the evaluated set at runtime (when the branch succeeds). Setting such a key via + // unevaluatedProperties()->set() would bypass the branch's own type/constraint + // validation, so the shim must reject those keys with the same exception used for + // directly-declared properties. + [$compositionProperties, $compositionPatterns] = $this->harvestCompositionPropertyNames($schema); + + $objectProperties = array_values(array_unique(array_merge($directProperties, $compositionProperties))); + $patternProperties = array_values(array_unique(array_merge($directPatterns, $compositionPatterns))); + + $hasObjectProperties = $objectProperties !== []; + $hasPatternProperties = $patternProperties !== []; + + if ($hasObjectProperties || $hasPatternProperties) { + $schema->addUsedClass(RegularPropertyAsUnevaluatedPropertyException::class); + } + + $schema->addMethod( + '_setUnevaluatedProperty', + new RenderedMethod( + $schema, + $generatorConfiguration, + 'UnevaluatedProperties/SetUnevaluatedProperty.phptpl', + [ + 'validationProperty' => $validationProperty, + 'hasObjectProperties' => $hasObjectProperties, + 'objectProperties' => RenderHelper::varExportArray($objectProperties), + 'hasPatternProperties' => $hasPatternProperties, + 'patternProperties' => $hasPatternProperties + ? RenderHelper::varExportPcrePatterns($patternProperties) + : null, + 'schemaHookResolver' => new SchemaHookResolver($schema), + ], + ), + ); + } + + /** + * @throws SchemaException + */ + private function addRemoveUnevaluatedPropertyMethod( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + ): void { + $minPropertyValidator = null; + $json = $schema->getJsonSchema()->getJson(); + if (isset($json['minProperties'])) { + $minPropertyValidator = new PropertyValidator( + new Property($schema->getClassName(), null, $schema->getJsonSchema()), + sprintf( + '($updatedPropertiesCount = count($this->_rawModelDataInput) - 1) < %d', + $json['minProperties'], + ), + MinPropertiesException::class, + [$json['minProperties'], '&$updatedPropertiesCount'], + ); + } + + $schema->addMethod( + '_removeUnevaluatedProperty', + new RenderedMethod( + $schema, + $generatorConfiguration, + 'UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl', + ['minPropertyValidator' => $minPropertyValidator], + ), + ); + } + + /** + * @throws FileSystemException + */ + protected function renderCompanionFromEntry(array $entry): void + { + $this->renderCompanionClass( + $entry['schema'], + $entry['generatorConfiguration'], + $entry['validationProperty'], + ); + } + + /** + * @throws FileSystemException + */ + private function renderCompanionClass( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + PropertyInterface $validationProperty, + ): void { + $renderHelper = new RenderHelper($generatorConfiguration); + $isImmutable = $generatorConfiguration->isImmutable(); + $companionClassName = $schema->getClassName() . 'UnevaluatedProperties'; + + $namespace = $this->resolveCompanionNamespace($schema, $generatorConfiguration); + + // get() always returns null when the key is absent, so the companion's get() return + // type must allow null in addition to the schema-declared type. + $nullableProperty = (clone $validationProperty)->setType( + $validationProperty->getType(), + new PropertyType($validationProperty->getType(true)->getNames(), true), + ); + + // The companion is a stand-alone class that mirrors the production-library accessor's + // shape with narrowed types — it does not extend the base. This matches the pattern in + // AdditionalPropertiesCompanion.phptpl. The base remains the fallback when no typed + // companion is generated. + $use = RenderHelper::filterClassImports( + $isImmutable ? [] : ['Closure'], + $namespace, + ); + + $this->writeAndRequireCompanionFile( + $schema, + $companionClassName, + $namespace, + join(DIRECTORY_SEPARATOR, ['Companion', 'UnevaluatedPropertiesCompanion.phptpl']), + [ + 'namespace' => $namespace, + 'use' => $use, + 'companionClassName' => $companionClassName, + 'immutable' => $isImmutable, + 'getReturnType' => $renderHelper->getTypeHintAnnotation($nullableProperty, true), + 'getNullablePhpType' => $renderHelper->getType($nullableProperty, true), + 'getAllReturnAnnotation' => $this->buildGetAllReturnAnnotation( + $renderHelper->getTypeHintAnnotation($validationProperty, true), + ), + 'setParameterType' => $renderHelper->getType($validationProperty), + 'setParameterAnnotation' => $renderHelper->getTypeHintAnnotation($validationProperty), + ], + ); + } + + private function buildGetAllReturnAnnotation(string $typeAnnotation): string + { + // Wrap union types in parentheses so that e.g. 'DateTime|null' becomes '(DateTime|null)[]', + // not 'DateTime[]|null[]' — the latter means "an array of nulls" to static analysers. + if (str_contains($typeAnnotation, '|')) { + return '(' . $typeAnnotation . ')[]'; + } + + return $typeAnnotation . '[]'; + } +} diff --git a/src/Templates/Validator/UnevaluatedProperties.phptpl b/src/Templates/Validator/UnevaluatedProperties.phptpl index 73a7ade2..ebb9e0dc 100644 --- a/src/Templates/Validator/UnevaluatedProperties.phptpl +++ b/src/Templates/Validator/UnevaluatedProperties.phptpl @@ -2,6 +2,9 @@ {% if generatorConfiguration.collectErrors() %} $originalErrorRegistry = $this->_errorRegistry; {% endif %} + {% if collectUnevaluatedProperties %} + $rollbackValues = $this->_unevaluatedProperties; + {% endif %} $unevaluatedKeys = $this->collectUnevaluatedKeys( $modelData, @@ -29,6 +32,10 @@ $invalidProperties[$propertyKey] = $this->_errorRegistry->getErrors(); } {% endif %} + + {% if collectUnevaluatedProperties %} + $this->_unevaluatedProperties[$propertyKey] = $value; + {% endif %} } catch (\Exception $e) { isset($invalidProperties[$propertyKey]) ? $invalidProperties[$propertyKey][] = $e @@ -40,5 +47,11 @@ $this->_errorRegistry = $originalErrorRegistry; {% endif %} + {% if collectUnevaluatedProperties %} + if (!empty($invalidProperties)) { + $this->_unevaluatedProperties = $rollbackValues; + } + {% endif %} + return !empty($invalidProperties); })() diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php new file mode 100644 index 00000000..55ff0004 --- /dev/null +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -0,0 +1,413 @@ +modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + } + + /** + * The typed accessor companion narrows get/set/getAll to the declared schema type. + * Construction collects matching extras into the backing field; getAll() returns them; + * get() returns one; immutable mode omits the mutators. + */ + public function testTypedAccessorRoundTripsCollectedExtras(): void + { + $this->addPostProcessor(); + + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + // Construction collects matching extras into _unevaluatedProperties. + $object = new $className(['name' => 'Alice', 'count' => 42, 'limit' => 7]); + $accessor = $object->unevaluatedProperties(); + + $this->assertSame(['count' => 42, 'limit' => 7], $accessor->getAll()); + $this->assertSame(42, $accessor->get('count')); + $this->assertSame(7, $accessor->get('limit')); + $this->assertNull($accessor->get('missing')); + + // Mutator path: set() routes through _setUnevaluatedProperty which runs validation + + // stores the value, and remove() takes the key out of both _unevaluatedProperties and + // _rawModelDataInput. + $accessor->set('extra', 99); + $this->assertSame(99, $accessor->get('extra')); + $this->assertSame( + ['name' => 'Alice', 'count' => 42, 'limit' => 7, 'extra' => 99], + $object->meta()->rawInput(), + ); + + $this->assertTrue($accessor->remove('extra')); + $this->assertNull($accessor->get('extra')); + $this->assertFalse($accessor->remove('does-not-exist')); + + // Same accessor instance is reused across calls (it's cached on the model). + $this->assertSame($accessor, $object->unevaluatedProperties()); + } + + /** + * The unevaluatedProperties subschema's runtime validators run on the shim and reject + * values that satisfy the PHP type signature but fail a constraint expressed only in + * the JSON Schema (here: maximum). The accessor's _unevaluatedProperties backing array + * and the raw model input must be untouched after a rejected set(). + */ + public function testSetRejectsValuesViolatingNonTypeConstraints(): void + { + $this->addPostProcessor(); + // Direct-exception mode (not error-collection) so the inner exception type is exact. + $className = $this->generateClassFromFile( + 'TypedExtrasWithRange.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['name' => 'Alice', 'count' => 1]); + $accessor = $object->unevaluatedProperties(); + + try { + // 999 satisfies the PHP `int` signature but violates `maximum: 100` in the schema. + $accessor->set('bad', 999); + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $exception) { + // Direct-exception mode: the unevaluated subschema's maximum validator fires first + // and the exception bubbles up with a message identifying the offending property. + $this->assertStringContainsString('unevaluated property', $exception->getMessage()); + } + + // Backing field unchanged after rollback inside the unevaluatedProperties template. + $this->assertSame(['count' => 1], $accessor->getAll()); + // Raw input did not record the partial mutation either. + $this->assertSame(['name' => 'Alice', 'count' => 1], $object->meta()->rawInput()); + } + + /** + * Keys that match a declared property name must be routed through the named setter and + * not the unevaluated accessor. The shim's runtime guard rejects them with a dedicated + * exception. + */ + public function testSetUnevaluatedPropertyRejectsDeclaredPropertyKey(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(['name' => 'Alice']); + + $this->expectException(RegularPropertyAsUnevaluatedPropertyException::class); + $object->unevaluatedProperties()->set('name', 42); + } + + /** + * The typed companion is a stand-alone class generated next to the model — it mirrors the + * production-library accessor's shape with narrowed signatures but does not extend it. + * (Matches the pattern used by AdditionalPropertiesAccessorPostProcessor.) Its short name + * follows {ModelName}UnevaluatedProperties. + */ + public function testCompanionClassNamingFollowsModelClassName(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(); + $accessor = $object->unevaluatedProperties(); + + $reflection = new \ReflectionClass($accessor); + $this->assertSame($className . 'UnevaluatedProperties', $reflection->getName()); + $this->assertTrue($reflection->hasMethod('get')); + $this->assertTrue($reflection->hasMethod('set')); + $this->assertTrue($reflection->hasMethod('getAll')); + $this->assertTrue($reflection->hasMethod('remove')); + } + + /** + * Immutable models receive only the read side of the accessor. When the schema is typed, + * the companion class is still emitted but omits the mutator methods entirely. + */ + public function testImmutableAccessorOmitsMutators(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(true), + ); + + $object = new $className(['name' => 'Alice', 'count' => 5]); + $accessor = $object->unevaluatedProperties(); + + $this->assertSame(5, $accessor->get('count')); + $this->assertSame(['count' => 5], $accessor->getAll()); + + // Immutable companion has no set/remove methods at all. + $this->assertFalse(method_exists($accessor, 'set')); + $this->assertFalse(method_exists($accessor, 'remove')); + } + + /** + * Composition branches contribute their declared `properties` keys to the evaluated set + * at runtime. The shim must reject those keys for the same reason it rejects locally- + * declared ones: routing them through the unevaluated accessor would bypass the branch's + * own type validation and silently store a value that belongs to the branch's contract. + */ + public function testSetRejectsKeysDeclaredInCompositionBranch(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'AllOfBranchOwnsProperty.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(); + + $this->expectException(RegularPropertyAsUnevaluatedPropertyException::class); + // `branchOwned` is declared only inside the allOf branch, not on the outer schema. + // The accumulator would normally credit it to the branch — setting it via the + // unevaluated accessor must still throw. + $object->unevaluatedProperties()->set('branchOwned', 42); + } + + /** + * Collect-errors mode: a failed set() leaves the error registry populated. The shim's + * registry reset isolates each setter call so a caller that catches the failure can + * continue using the model — without the reset, every subsequent setter would throw + * stale errors from a prior call instead of the actual current-call errors. + */ + public function testSetterInCollectErrorsModeIsolatesEachCall(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtrasWithRange.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(true), + ); + + $object = new $className(['name' => 'Alice']); + $accessor = $object->unevaluatedProperties(); + + try { + $accessor->set('bad', 999); // exceeds maximum: 100 + $this->fail('First set() was expected to throw'); + } catch (\PHPModelGenerator\Exception\ErrorRegistryException) { + // expected — collect-errors mode wraps validation failures in this exception + } + + // The second call's value is valid — it must succeed even though the first call's + // errors are still stored on the registry field. + $accessor->set('ok', 50); + $this->assertSame(50, $accessor->get('ok')); + } + + /** + * Serialization round-trip: the _unevaluatedProperties backing field is flattened back + * into toArray()/toJSON() output by SerializableTrait::_serializeUnevaluatedProperties. + * Keys claimed at construction time and keys added via set() must both appear in the + * serialized representation. + */ + public function testSerializationFlattensUnevaluatedPropertiesIntoOutput(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration()) + ->setImmutable(false) + ->setSerialization(true), + ); + + $object = new $className(['name' => 'Alice', 'count' => 1, 'limit' => 7]); + $object->unevaluatedProperties()->set('extra', 99); + + $this->assertEqualsCanonicalizing( + ['name' => 'Alice', 'count' => 1, 'limit' => 7, 'extra' => 99], + $object->toArray(), + ); + + $decoded = json_decode($object->toJSON(), true); + $this->assertEqualsCanonicalizing( + ['name' => 'Alice', 'count' => 1, 'limit' => 7, 'extra' => 99], + $decoded, + ); + } + + /** + * `unevaluatedProperties: false` produces a NoUnevaluatedPropertiesValidator with no + * backing storage to expose, so the accessor method must not be emitted at all. + */ + public function testUnevaluatedFalseSuppressesAccessorEmission(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile('UnevaluatedFalse.json'); + + $object = new $className(['name' => 'Alice']); + + $this->assertFalse(method_exists($object, 'unevaluatedProperties')); + } + + /** + * Configures both accessor post processors and the serialization post processor on the + * generator so the same code path the user would hit when enabling both accessors + * simultaneously is exercised. + */ + private function addBothAccessorPostProcessors(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new AdditionalPropertiesAccessorPostProcessor()); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + } + + /** + * @return array + * [schemaFile, expectAdditionalAccessor, expectUnevaluatedAccessor] + */ + public static function bothAccessorsEmissionMatrix(): array + { + return [ + // additionalProperties absent, unevaluatedProperties is a typed schema — + // unevaluated owns the bucket; additional accessor needs an explicit opt-in for + // schemas without additionalProperties so it is NOT emitted by default. + 'additional absent, unevaluated typed' => [ + 'TypedExtras.json', + false, + true, + ], + // additionalProperties: true is treated as "claim every extra without validation" + // → additional accessor emits; unevaluated has nothing left to claim and is skipped + // at the factory level (no UnevaluatedPropertiesValidator emitted). + 'additional true, unevaluated typed' => [ + 'AdditionalTrueWithUnevaluatedSchema.json', + true, + false, + ], + // additionalProperties: {schema} validates and claims every extra; unevaluated + // suppressed for the same reason as the true case. + 'additional typed schema, unevaluated typed' => [ + 'AdditionalSchemaWithUnevaluatedSchema.json', + true, + false, + ], + // additionalProperties: false rejects every extra at validation time — neither + // accessor has a bucket to expose; both are suppressed. + 'additional false, unevaluated typed' => [ + 'AdditionalFalseWithUnevaluatedSchema.json', + false, + false, + ], + ]; + } + + /** + * When both accessor post processors are configured at the same time, each one's emission + * policy must independently respect the schema shape. The expected combination depends on + * which keyword owns the bucket of extras. + */ + #[DataProvider('bothAccessorsEmissionMatrix')] + public function testCoexistenceEmissionPolicy( + string $schemaFile, + bool $expectAdditionalAccessor, + bool $expectUnevaluatedAccessor, + ): void { + $this->addBothAccessorPostProcessors(); + $className = $this->generateClassFromFile($schemaFile); + + $this->assertSame( + $expectAdditionalAccessor, + method_exists($className, 'additionalProperties'), + 'additionalProperties() accessor emission did not match the expected shape', + ); + $this->assertSame( + $expectUnevaluatedAccessor, + method_exists($className, 'unevaluatedProperties'), + 'unevaluatedProperties() accessor emission did not match the expected shape', + ); + } + + /** + * Runtime coexistence: when additionalProperties is configured AND a typed unevaluated + * schema is declared, additionalProperties wins at the factory level — extras land in + * _additionalProperties via the additional accessor, never in _unevaluatedProperties. + * Both serializations round-trip through `toArray()` correctly. + */ + public function testAdditionalTrueClaimsExtrasAndUnevaluatedBucketRemainsEmpty(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new AdditionalPropertiesAccessorPostProcessor()); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + + $className = $this->generateClassFromFile( + 'AdditionalTrueWithUnevaluatedSchema.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + $object = new $className(['name' => 'Alice', 'extra' => 'hello']); + + $additionalAccessor = $object->additionalProperties(); + $this->assertSame(['extra' => 'hello'], $additionalAccessor->getAll()); + + // Confirm the unevaluatedProperties accessor was not emitted (additionalProperties: true + // suppresses it because additional claims every extra at runtime). + $this->assertFalse(method_exists($object, 'unevaluatedProperties')); + + // Serialization flattens the additional bucket back into the output. + $this->assertEqualsCanonicalizing( + ['name' => 'Alice', 'extra' => 'hello'], + $object->toArray(), + ); + } + + /** + * Runtime coexistence: additionalProperties: false rejects every extra at construction + * time even though the user enabled both accessor post processors. The unevaluated + * accessor is suppressed at codegen because its bucket would be unreachable. + */ + public function testAdditionalFalseRejectsExtrasAndSuppressesUnevaluatedAccessor(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new AdditionalPropertiesAccessorPostProcessor()); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + + $className = $this->generateClassFromFile( + 'AdditionalFalseWithUnevaluatedSchema.json', + (new GeneratorConfiguration())->setCollectErrors(false), + ); + + // Constructing with no extras is fine. + $object = new $className(['name' => 'Alice']); + $this->assertFalse(method_exists($object, 'additionalProperties')); + $this->assertFalse(method_exists($object, 'unevaluatedProperties')); + + // Constructing with an extra throws on the additionalProperties: false validator. + $this->expectException(\PHPModelGenerator\Exception\ValidationException::class); + new $className(['name' => 'Alice', 'extra' => 42]); + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalFalseWithUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalFalseWithUnevaluatedSchema.json new file mode 100644 index 00000000..31817051 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalFalseWithUnevaluatedSchema.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalSchemaWithUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalSchemaWithUnevaluatedSchema.json new file mode 100644 index 00000000..291aa71c --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalSchemaWithUnevaluatedSchema.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + }, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalTrueWithUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalTrueWithUnevaluatedSchema.json new file mode 100644 index 00000000..eb9c7529 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalTrueWithUnevaluatedSchema.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": true, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AllOfBranchOwnsProperty.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AllOfBranchOwnsProperty.json new file mode 100644 index 00000000..ae9190c0 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AllOfBranchOwnsProperty.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "allOf": [ + { + "properties": { + "branchOwned": { + "type": "string" + } + } + } + ], + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtras.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtras.json new file mode 100644 index 00000000..33998863 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtras.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithRange.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithRange.json new file mode 100644 index 00000000..228d28c3 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithRange.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "integer", + "maximum": 100 + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UnevaluatedFalse.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UnevaluatedFalse.json new file mode 100644 index 00000000..59b98669 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UnevaluatedFalse.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": false +} From 7a77edadfa2c0f294e95df4dbf482aadfa85c35d Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 11 Jun 2026 13:20:32 +0200 Subject: [PATCH 07/51] Run cross-state validation for setters and populate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits _executeBaseValidators into two phase methods so the constructor, setters, and populate can invoke whichever phases they need against whichever input they need. The schema-level validators that depend on the merged state of every property — currently just unevaluatedProperties — now live in _executePostCompositionValidators, and the property-level plus composition validators stay in _executeBaseValidators. Constructor calls each method in turn against the raw input. Setters call _executePostCompositionValidators against a candidate raw input (current state with the new value plugged in) before commit, so a setter that flips a oneOf discriminator and would orphan a key from the previous branch's contract is rejected before the model state changes. populate() runs the delta check via _executeBaseValidators (which no longer includes the post-composition phase) and then runs _executePostCompositionValidators against the would-be merged state; failure rolls every committed property field back and leaves _rawModelDataInput untouched. The accessor's _setUnevaluatedProperty shim now calls _executePostCompositionValidators directly: a single dynamic key only ever needs the post-composition phase, not the schema's full base + composition chain. Each call resets its own error registry so a caller catching a failed set() can continue to use the model. Test class UnevaluatedPropertiesMutabilityTest covers the setter rollback path on each composition shape (oneOf, anyOf overlap, anyOf sole-coverer, if/then/else), the no-op early-return path, populate's merged-state rollback, the collect-errors mode collation of property-validator failures together with unevaluated-properties failures in a single registry, and the cross-state revalidation reading through nested-branch class instances. Co-Authored-By: Claude Opus 4.7 --- .../UnevaluatedPropertiesPostProcessor.php | 24 +- .../PostProcessor/Templates/Populate.phptpl | 11 + .../SetUnevaluatedProperty.phptpl | 11 +- src/Templates/Model.phptpl | 39 ++- .../UnevaluatedPropertiesMutabilityTest.php | 284 ++++++++++++++++++ .../AllOfNestedBranches.json | 25 ++ .../AnyOfOverlappingCoverage.json | 32 ++ .../AnyOfSoleCoverer.json | 29 ++ .../IfThenElseDiscriminator.json | 31 ++ .../OneOfKindDiscriminator.json | 32 ++ ...eOfKindDiscriminatorConstrainedString.json | 33 ++ 11 files changed, 540 insertions(+), 11 deletions(-) create mode 100644 tests/Basic/UnevaluatedPropertiesMutabilityTest.php create mode 100644 tests/Schema/UnevaluatedPropertiesMutabilityTest/AllOfNestedBranches.json create mode 100644 tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfOverlappingCoverage.json create mode 100644 tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfSoleCoverer.json create mode 100644 tests/Schema/UnevaluatedPropertiesMutabilityTest/IfThenElseDiscriminator.json create mode 100644 tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminator.json create mode 100644 tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminatorConstrainedString.json diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php index 737c5da3..d3c076f9 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -16,13 +16,27 @@ use PHPModelGenerator\Traits\CompositionEvaluationTrait; /** - * Detects whether unevaluatedProperties or unevaluatedItems is reachable from a schema - * and, when it is, activates evaluation tracking on that schema's composition validators. + * Detects whether unevaluatedProperties or unevaluatedItems is reachable from a schema and, + * when it is, activates evaluation tracking on that schema's composition validators. * * Activation emits the _compositionEvaluations cache field on the generated class and marks - * each composition validator with per-branch slot writes. - * When neither keyword is reachable anywhere in the schema graph, this post processor is a - * complete no-op and generates no extra code. + * each composition validator with per-branch slot writes. When neither keyword is reachable + * anywhere in the schema graph, this post processor is a complete no-op. + * + * Cross-state revalidation under mutation (setter + populate) is *not* handled here. It lives + * in Model.phptpl and Populate.phptpl as a direct call to _executePostCompositionValidators + * against a candidate state — see the templates for details. The unevaluatedProperties + * validator participates in that pass simply by being registered as a post-composition + * validator on the schema; this post processor's only responsibility is the activation-side + * work that makes the cache available to it. + * + * Known limitation: when a cross-state check fails in direct-exception mode, composition + * validation that ran during the setter's per-property _validate* phase may have updated + * _propertyValidationState / _compositionEvaluations with state that was never committed. + * The cache may briefly hold "would-be" entries until the next mutation invalidates them. + * This is a pre-existing concern with composition revalidation, not specific to the + * unevaluatedProperties feature, and self-corrects the next time an affecting property + * changes. */ class UnevaluatedPropertiesPostProcessor extends PostProcessor { diff --git a/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl index fe2cb41e..d87c9446 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl @@ -53,6 +53,17 @@ public function populate(array $modelData): self {% endif %} {% endforeach %} + {% if schema.getPostCompositionValidators() %} + // Cross-state validation: the per-property loop above only sees the populate delta, so + // violations that depend on the union of pre-existing state plus the delta (notably + // unevaluatedProperties claims that flip with a discriminator) need to be checked + // against the would-be merged state. The check runs without committing the merge — + // the rollback paths below restore property fields if it fails, leaving the original + // _rawModelDataInput untouched on failure. + $candidateRawModelDataInput = array_merge($this->_rawModelDataInput, $modelData); + $this->_executePostCompositionValidators($candidateRawModelDataInput); + {% endif %} + {% if generatorConfiguration.collectErrors() %} if (count($this->_errorRegistry->getErrors())) { foreach ($rollbackValues as $property => $value) { diff --git a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl index ac1a05a3..3e7bcab1 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl @@ -26,9 +26,12 @@ private function _setUnevaluatedProperty( {% if validationProperty %}{{ schemaHookResolver.resolveSetterBeforeValidationHook(validationProperty) }}{% endif %} - // _executeBaseValidators runs the unevaluatedProperties validator against the candidate - // pair, which writes the value into $this->_unevaluatedProperties on success. In - // collect-errors mode each call needs an isolated error registry — without resetting it, + // _executePostCompositionValidators runs the unevaluatedProperties validator against the + // candidate pair, which writes the value into $this->_unevaluatedProperties on success. + // The schema's base + composition validators don't apply to a single dynamic key (the key + // isn't a declared property), so only the post-composition phase needs to run here. + // + // In collect-errors mode each call needs an isolated error registry — without resetting it, // a previous setter that failed and was caught by the caller would leave stale errors that // make every subsequent setter throw spuriously when its own validation actually succeeded. {% if generatorConfiguration.collectErrors() %} @@ -36,7 +39,7 @@ private function _setUnevaluatedProperty( {% endif %} $addedProperty = [$key => $value]; - $this->_executeBaseValidators($addedProperty); + $this->_executePostCompositionValidators($addedProperty); {% if generatorConfiguration.collectErrors() %} if (count($this->_errorRegistry->getErrors())) { diff --git a/src/Templates/Model.phptpl b/src/Templates/Model.phptpl index 603cfabc..99b1748d 100644 --- a/src/Templates/Model.phptpl +++ b/src/Templates/Model.phptpl @@ -66,10 +66,14 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v {{ schemaHookResolver.resolveConstructorBeforeValidationHook() }} - {% if schema.getBaseValidators() or schema.getPostCompositionValidators() %} + {% if schema.getBaseValidators() %} $this->_executeBaseValidators($rawModelDataInput); {% endif %} + {% if schema.getPostCompositionValidators() %} + $this->_executePostCompositionValidators($rawModelDataInput); + {% endif %} + {% foreach schema.getProperties() as property %} {% if not property.isInternal() %} $this->_process{{ viewHelper.ucfirst(property.getAttribute()) }}($rawModelDataInput); @@ -87,7 +91,13 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v {{ schemaHookResolver.resolveConstructorAfterValidationHook() }} } - {% if schema.getBaseValidators() or schema.getPostCompositionValidators() %} + {% if schema.getBaseValidators() %} + /** + * Runs property-level validators and composition validators against $modelData. Used + * for the constructor's full validation pass and for populate()'s per-property delta + * check. Schema-level validators that depend on the merged cross-property state live + * in _executePostCompositionValidators(). + */ protected function _executeBaseValidators(array &$modelData): void { $value = &$modelData; @@ -99,6 +109,20 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v {% foreach schema.getCompositionValidatorKeys() as compositionValidatorKey %} $this->_validateComposition_{{ compositionValidatorKey }}($modelData); {% endforeach %} + } + {% endif %} + + {% if schema.getPostCompositionValidators() %} + /** + * Runs schema-level validators that depend on the merged state of every property — + * currently just unevaluatedProperties. The constructor calls this on the raw input + * after _executeBaseValidators; setters call it against a candidate state (raw input + * with the new value plugged in) before commit; populate() calls it against the + * would-be merged state before applying the merge. + */ + protected function _executePostCompositionValidators(array &$modelData): void + { + $value = &$modelData; {% foreach schema.getPostCompositionValidators() as validator %} {{ viewHelper.renderValidator(validator, schema) }} @@ -158,6 +182,17 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v $value = $this->_validate{{ viewHelper.ucfirst(property.getAttribute()) }}($value, $modelData); + {% if schema.getPostCompositionValidators() %} + // Cross-state validation: build a candidate raw input with the new + // value plugged in so post-composition validators (notably + // unevaluatedProperties) see the would-be state instead of the still + // pre-mutation $this->_rawModelDataInput. Validates without committing — + // the commit below only runs after every validator passes. + $candidateRawModelDataInput = $this->_rawModelDataInput; + $candidateRawModelDataInput['{{ property.getName() }}'] = ${{ property.getAttribute(true) }}; + $this->_executePostCompositionValidators($candidateRawModelDataInput); + {% endif %} + {% if generatorConfiguration.collectErrors() %} if ($this->_errorRegistry->getErrors()) { throw $this->_errorRegistry; diff --git a/tests/Basic/UnevaluatedPropertiesMutabilityTest.php b/tests/Basic/UnevaluatedPropertiesMutabilityTest.php new file mode 100644 index 00000000..771b8c23 --- /dev/null +++ b/tests/Basic/UnevaluatedPropertiesMutabilityTest.php @@ -0,0 +1,284 @@ +setImmutable(false)->setCollectErrors(false); + } + + /** + * Two assertions on the same generated class: + * - re-setting kind to the same value hits the setter's early-return guard so no + * validation or revalidation runs and no observable state changes; + * - flipping the discriminator orphans alphaOnly under the new branch — the setter + * throws UnevaluatedPropertiesException carrying a message that names the orphaned + * key, and rolls every field back to the pre-setter state. + */ + public function testOneOfDiscriminatorFlipRollsBackAndSameValueIsNoOp(): void + { + $className = $this->generateClassFromFile('OneOfKindDiscriminator.json', $this->defaultConfig()); + $object = new $className(['kind' => 'a', 'alphaOnly' => 1]); + + // Same-value setter is an early return — neither the validator nor the revalidation + // hook runs because the setter's first check returns before either fires. + $object->setKind('a'); + $this->assertSame('a', $object->getKind()); + $this->assertSame(['kind' => 'a', 'alphaOnly' => 1], $object->meta()->rawInput()); + + // Flipping the discriminator: branch 1 succeeds but does not claim alphaOnly, so the + // outer unevaluatedProperties:false rejects after the setter commits. The hook's + // catch branch restores every snapshotted field. + try { + $object->setKind('b'); + $this->fail('Expected setKind to throw because alphaOnly becomes unevaluated'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[alphaOnly\]$/', + $exception->getMessage(), + ); + $this->assertSame(['alphaOnly'], $exception->getUnevaluatedProperties()); + } + $this->assertSame('a', $object->getKind()); + $this->assertSame(['kind' => 'a', 'alphaOnly' => 1], $object->meta()->rawInput()); + } + + /** + * if/then/else discriminator flip. Same combined shape as the oneOf test: + * - same-value setter is a no-op; + * - flipping mode swaps which branch (`then` or `else`) contributes evaluated names; + * the prior branch's inline key (`onlyWhenOn`) becomes unevaluated; the setter + * throws and the model rolls back. + */ + public function testIfThenElseFlipRollsBackAndSameValueIsNoOp(): void + { + $className = $this->generateClassFromFile('IfThenElseDiscriminator.json', $this->defaultConfig()); + $object = new $className(['mode' => 'on', 'onlyWhenOn' => 5]); + + $object->setMode('on'); + $this->assertSame('on', $object->getMode()); + $this->assertSame(['mode' => 'on', 'onlyWhenOn' => 5], $object->meta()->rawInput()); + + try { + $object->setMode('off'); + $this->fail('Expected setMode to throw because onlyWhenOn becomes unevaluated'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[onlyWhenOn\]$/', + $exception->getMessage(), + ); + $this->assertSame(['onlyWhenOn'], $exception->getUnevaluatedProperties()); + } + $this->assertSame('on', $object->getMode()); + $this->assertSame(['mode' => 'on', 'onlyWhenOn' => 5], $object->meta()->rawInput()); + } + + /** + * anyOf with two branches that both claim `shared`. After setKind('secondary') branch 0 + * (constrained by `const: primary`) fails but branch 1 (any string kind) keeps + * succeeding and still claims `shared`. Because at least one successful branch still + * covers the key, the unevaluated check accepts and the setter completes silently. + */ + public function testAnyOfOverlappingCoverageKeepsKeyEvaluatedAfterFlip(): void + { + $className = $this->generateClassFromFile('AnyOfOverlappingCoverage.json', $this->defaultConfig()); + $object = new $className(['kind' => 'primary', 'shared' => 7]); + + $object->setKind('secondary'); + + $this->assertSame('secondary', $object->getKind()); + $this->assertSame(['kind' => 'secondary', 'shared' => 7], $object->meta()->rawInput()); + } + + /** + * anyOf where each branch declares its own kind-keyed properties and `xOnly` is only + * claimed by branch 0. Flipping kind makes branch 0 fail; branch 1 succeeds but does + * not claim xOnly, so the outer unevaluatedProperties:false rejects and the setter + * rolls back. Exercises the same code path as the oneOf and if/then/else flip tests + * but with a distinct composition shape so the harvest of composition-branch names by + * the activation walk is exercised against anyOf too. + */ + public function testAnyOfSoleCovererFlipMakesOrphanedKeyUnevaluated(): void + { + $className = $this->generateClassFromFile('AnyOfSoleCoverer.json', $this->defaultConfig()); + $object = new $className(['kind' => 'x', 'xOnly' => 3]); + + try { + $object->setKind('y'); + $this->fail('Expected setKind to throw because xOnly loses its sole coverer'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[xOnly\]$/', + $exception->getMessage(), + ); + $this->assertSame(['xOnly'], $exception->getUnevaluatedProperties()); + } + + $this->assertSame('x', $object->getKind()); + $this->assertSame(['kind' => 'x', 'xOnly' => 3], $object->meta()->rawInput()); + } + + /** + * Nested-branch composition: the parent's `type: object` causes the allOf branches to be + * rendered as separate nested classes rather than merged inline. The cache stores the + * nested instance reference and the accumulator rebuild calls getEvaluatedProperties() + * on it. Two assertions on the same generated class: + * - extra keys are rejected at construction even when the branches live in nested + * classes (proves the activation/cache wiring fires for nested-branch composition); + * - a setter on the outer property triggers revalidation, which reads through the + * nested instances correctly and accepts because every key is still covered. + */ + public function testNestedBranchSchemaRejectsExtrasAndRevalidatesThroughNestedInstances(): void + { + $className = $this->generateClassFromFile('AllOfNestedBranches.json', $this->defaultConfig()); + + try { + new $className(['name' => 'Alice', 'foo' => 'hello', 'bar' => 42, 'stray' => 'unclaimed']); + $this->fail('constructor must reject the stray key claimed by no branch'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[stray\]$/', + $exception->getMessage(), + ); + $this->assertSame(['stray'], $exception->getUnevaluatedProperties()); + } + + $object = new $className(['name' => 'Alice', 'foo' => 'hello', 'bar' => 42]); + $object->setName('Bob'); + + $this->assertSame('Bob', $object->getName()); + $this->assertSame( + ['name' => 'Bob', 'foo' => 'hello', 'bar' => 42], + $object->meta()->rawInput(), + ); + } + + /** + * populate() that breaks the unevaluated check rolls every snapshotted field back so the + * caller observing the throw sees the pre-call state. Populate.phptpl runs a single + * one-shot revalidation after the merge — the per-property hooks emit empty code in + * batch mode — and the rollback restores both rollbackValues and _rawModelDataInput. + */ + public function testPopulateRollsBackEveryFieldWhenDeltaBreaksUnevaluated(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new PopulatePostProcessor()); + }; + + $className = $this->generateClassFromFile('OneOfKindDiscriminator.json', $this->defaultConfig()); + $object = new $className(['kind' => 'a', 'alphaOnly' => 1]); + + try { + $object->populate(['kind' => 'b']); + $this->fail('Expected populate to throw because alphaOnly becomes unevaluated'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[alphaOnly\]$/', + $exception->getMessage(), + ); + $this->assertSame(['alphaOnly'], $exception->getUnevaluatedProperties()); + } + + $this->assertSame('a', $object->getKind()); + $this->assertSame(['kind' => 'a', 'alphaOnly' => 1], $object->meta()->rawInput()); + } + + /** + * Collect-errors mode: the registry accumulates failures rather than throwing on the + * first one. The hook's collect-errors emission compares the registry's state after + * the revalidate call and surfaces the registry when new errors landed. Confirms the + * codegen-time branch picks the right shape for collect-errors mode and that the + * collected error carries the orphaned-key message produced by the unevaluated + * validator. + */ + /** + * Collect-errors mode must surface every error detected by a setter in one exception, + * regardless of which validation phase recorded it. This setter triggers both a + * property-validator failure (kind's minLength constraint) and an unevaluated-properties + * violation (alphaOnly becomes orphaned when the discriminator flips). The registry + * should carry both errors so the caller can fix everything in one round. + * + * Asserts the full property-validator message, the full unevaluated-properties message, + * and that the model state is rolled back to its pre-setter values. + */ + public function testCollectErrorsModeCollectsValidatorAndUnevaluatedErrorsTogether(): void + { + $className = $this->generateClassFromFile( + 'OneOfKindDiscriminatorConstrainedString.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(true), + ); + + $object = new $className(['kind' => 'aaa', 'alphaOnly' => 1]); + + try { + // Two-char value: violates kind's minLength: 3 *and* fails both oneOf branches' + // const checks. With the setter deferring its registry throw past the after-hook + // the unevaluated revalidation still runs and contributes its own error to the + // registry before the setter finally throws. + $object->setKind('bb'); + $this->fail('Expected setKind to throw an aggregated error registry'); + } catch (ErrorRegistryException $registry) { + $errors = $registry->getErrors(); + + $minLengthErrors = array_values(array_filter( + $errors, + static fn(\Throwable $error): bool => $error instanceof MinLengthException, + )); + $unevaluatedErrors = array_values(array_filter( + $errors, + static fn(\Throwable $error): bool => $error instanceof UnevaluatedPropertiesException, + )); + + $this->assertCount( + 1, + $minLengthErrors, + 'expected one MinLengthException; got messages: ' . implode( + ' | ', + array_map(static fn(\Throwable $e): string => $e->getMessage(), $errors), + ), + ); + $this->assertSame('Value for kind must not be shorter than 3', $minLengthErrors[0]->getMessage()); + + $this->assertCount(1, $unevaluatedErrors, 'expected one UnevaluatedPropertiesException'); + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[alphaOnly\]$/', + $unevaluatedErrors[0]->getMessage(), + ); + $this->assertSame(['alphaOnly'], $unevaluatedErrors[0]->getUnevaluatedProperties()); + } + + // The model is rolled back: every assertion below would fail with the half-applied + // mutation that the prior (eager-throw) behaviour produced. + $this->assertSame('aaa', $object->getKind()); + $this->assertSame(['kind' => 'aaa', 'alphaOnly' => 1], $object->meta()->rawInput()); + } +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/AllOfNestedBranches.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/AllOfNestedBranches.json new file mode 100644 index 00000000..9d3ecb68 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/AllOfNestedBranches.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "properties": { + "bar": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfOverlappingCoverage.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfOverlappingCoverage.json new file mode 100644 index 00000000..75f9f1b3 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfOverlappingCoverage.json @@ -0,0 +1,32 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "required": ["kind"], + "anyOf": [ + { + "properties": { + "kind": { + "const": "primary" + }, + "shared": { + "type": "integer" + } + } + }, + { + "properties": { + "kind": { + "type": "string" + }, + "shared": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfSoleCoverer.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfSoleCoverer.json new file mode 100644 index 00000000..16e2fc16 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfSoleCoverer.json @@ -0,0 +1,29 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "required": ["kind"], + "anyOf": [ + { + "properties": { + "kind": { + "const": "x" + }, + "xOnly": { + "type": "integer" + } + } + }, + { + "properties": { + "kind": { + "const": "y" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/IfThenElseDiscriminator.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/IfThenElseDiscriminator.json new file mode 100644 index 00000000..f0a12148 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/IfThenElseDiscriminator.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "properties": { + "mode": { + "type": "string" + } + }, + "required": ["mode"], + "if": { + "properties": { + "mode": { + "const": "on" + } + } + }, + "then": { + "properties": { + "onlyWhenOn": { + "type": "integer" + } + } + }, + "else": { + "properties": { + "onlyWhenOff": { + "type": "integer" + } + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminator.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminator.json new file mode 100644 index 00000000..07562027 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminator.json @@ -0,0 +1,32 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "required": ["kind"], + "oneOf": [ + { + "properties": { + "kind": { + "const": "a" + }, + "alphaOnly": { + "type": "integer" + } + } + }, + { + "properties": { + "kind": { + "const": "b" + }, + "betaOnly": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminatorConstrainedString.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminatorConstrainedString.json new file mode 100644 index 00000000..ed80d8e5 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminatorConstrainedString.json @@ -0,0 +1,33 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string", + "minLength": 3 + } + }, + "required": ["kind"], + "oneOf": [ + { + "properties": { + "kind": { + "const": "aaa" + }, + "alphaOnly": { + "type": "integer" + } + } + }, + { + "properties": { + "kind": { + "const": "bb" + }, + "betaOnly": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false +} From 157e80a6fb92ccbc3f91e4f466273a0f747201ae Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Wed, 17 Jun 2026 23:05:40 +0200 Subject: [PATCH 08/51] Close remaining Phase 4 work for unevaluatedProperties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire cross-state revalidation into _setAdditionalProperty and _removeAdditionalProperty so dynamic-key mutations see the post-mutation state. Set runs base validators followed by _executePostCompositionValidators against a candidate raw input. Remove additionally re-runs every composition validator against the post-removal raw input so _compositionEvaluations reflects the new branch outcomes, then runs the post-composition phase. Both share a single _errorRegistry across phases in collect-errors mode and snapshot/rollback _additionalProperties, _rawModelDataInput, _compositionEvaluations, and _propertyValidationState on rejection. Fix the PHP 8.4 dynamic-property deprecation triggered when a composition branch declares additionalProperties:{schema}: setupBranchDefaultHelpers no longer propagates internal properties from a branch's nested schema into allBranchDefaultAttributeMap. Internal properties have no getter and the parent never declares the corresponding field, so the propagation loop's $this->$attr = ... writes were creating dynamic properties on the parent. Emit a generation-time warning when unevaluatedProperties is suppressed because a sibling additionalProperties is non-false (the dead-code cell from the §4.1 matrix). Gate the warning on isOutputEnabled() for parity with the rest of the warning channel; gate the previously-unconditional echo in EnumPostProcessor::filterValuesByDeclaredType the same way; let the test infrastructure respect the caller's outputEnabled so warning-assertion tests can opt in. Test coverage added for §5.1.20 (required + unevaluated error precedence in both direct and collect modes), §5.1.A1 (defaults are not part of the evaluated set), §5.1.A3/A4 (branch-level additionalProperties per-key validity), §5.1.25 (pattern + unevaluated bucket coexistence), §5.1.27 (empty allOf), §5.1.M33-34 (additionalProperties accessor under outer unevaluatedProperties), §5.1.M37 (remove clears backing field plus raw input), §5.1.M42 (claimed-via-unevaluated key persists after sibling later covers), the dead-code suppression warning, the Set cross-state revalidation rejection path, and the dual-error collect-mode aggregation for both Set (pattern + unevaluated, paired with the per-key validity fix on the production library) and Remove (min-property + unevaluated via composition revalidation flipping an anyOf branch). Both dual-error tests assert the full ErrorRegistryException message via heredoc. CLAUDE.md adds a rule requiring multi-line exception-message assertions to use heredoc inlined into the assertSame call. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 13 + .../AbstractComposedPropertyValidator.php | 7 + .../UnevaluatedPropertiesValidatorFactory.php | 11 +- .../PostProcessor/EnumPostProcessor.php | 11 +- .../RemoveAdditionalProperty.phptpl | 62 ++- .../SetAdditionalProperty.phptpl | 33 +- tests/AbstractPHPModelGeneratorTestCase.php | 6 +- .../UnevaluatedPropertiesValidatorTest.php | 162 ++++++++ ...tedPropertiesAccessorPostProcessorTest.php | 353 +++++++++++++++++- .../BranchFlipOnRemove.json | 35 ++ .../KindDiscriminatorTypedExtras.json | 33 ++ .../MinPropertiesAndUnevaluatedSchema.json | 12 + .../NoAdditionalUnevaluatedFalse.json | 9 + .../PatternAndUnevaluatedCoexist.json | 16 + .../PatternWithUnevaluatedFalse.json | 14 + .../BranchLevelAdditionalProperties.json | 29 ++ .../DefaultsNotEvaluated.json | 13 + .../EmptyAllOf.json | 10 + .../RequiredPropertyFailsFirst.json | 12 + 19 files changed, 821 insertions(+), 20 deletions(-) create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/KindDiscriminatorTypedExtras.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/MinPropertiesAndUnevaluatedSchema.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NoAdditionalUnevaluatedFalse.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternAndUnevaluatedCoexist.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternWithUnevaluatedFalse.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/BranchLevelAdditionalProperties.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/DefaultsNotEvaluated.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/EmptyAllOf.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/RequiredPropertyFailsFirst.json diff --git a/CLAUDE.md b/CLAUDE.md index 3201f054..1b6eb178 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -431,6 +431,19 @@ Never use multiple `assertStringContainsString` calls on the same exception mess message can be constructed. A single `assertSame($expectedMessage, $exception->getMessage())` is both stronger and self-documenting. +When the expected exception message spans multiple lines (e.g. an `ErrorRegistryException` +joining several sub-errors with `"\n"`, or any nested-exception format that embeds newlines), +**always write the expected value as a heredoc**, never as a `sprintf` call with `\n` escapes +or as concatenated `.` string fragments. Heredoc preserves the literal layout of the message +exactly as it will appear at runtime, so the test source reads as the message and a diff +against the actual output is line-by-line. Use the variable-interpolating `<<getNestedSchema()->getProperties() as $branchProperty) { + // Internal properties (post-processor collection buckets, accumulator caches) + // have no getter and the parent never declares the field — propagating them + // would emit a dynamic-property write on the parent. + if ($branchProperty->isInternal()) { + continue; + } + $propertyAccessors[$branchProperty->getName()] = 'get' . ucfirst($branchProperty->getAttribute()); if ($branchProperty->getDefaultValue() === null) { diff --git a/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php index e68bf9e5..8262382e 100644 --- a/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php @@ -36,8 +36,17 @@ public function modify( // claimed by `properties`/`patternProperties` is already evaluated by it. Composition // contributions cannot retract that — `additionalProperties` only succeeds when every // matched key passes — so the unevaluated set is guaranteed empty and the validator - // would be a no-op. Skip emission entirely. + // would be a no-op. Skip emission entirely and warn the developer that the keyword + // is dead code at this schema level. if (isset($json['additionalProperties']) && $json['additionalProperties'] !== false) { + if ($schemaProcessor->getGeneratorConfiguration()->isOutputEnabled()) { + echo sprintf( + "Warning: unevaluatedProperties on %s is dead code — sibling additionalProperties" + . " already claims every extra key\n", + $schema->getClassName(), + ); + } + return; } diff --git a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php index 12118de1..8cdf1aac 100644 --- a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php @@ -92,7 +92,7 @@ private function processProperty( if (isset($json['enum']) && !$this->hasEnumFilterAlreadyApplied($property)) { // Filter incompatible values before validation so that e.g. a string-typed enum // with a stray integer value is still valid (and the integer is removed with a warning). - $values = $this->filterValuesByDeclaredType($json, $property); + $values = $this->filterValuesByDeclaredType($json, $property, $generatorConfiguration); if ($this->validateEnum($property, $values)) { $this->convertEnumProperty($property, $schema, $generatorConfiguration, $json, $values); @@ -324,8 +324,11 @@ private function validateEnum(PropertyInterface $property, array $values): bool * Removes values that can never satisfy the type constraint and emits a warning for each * removed value so the developer is aware at generation time. */ - private function filterValuesByDeclaredType(array $json, PropertyInterface $property): array - { + private function filterValuesByDeclaredType( + array $json, + PropertyInterface $property, + GeneratorConfiguration $generatorConfiguration, + ): array { $values = $json['enum']; if (!isset($json['type'])) { @@ -367,7 +370,7 @@ private function filterValuesByDeclaredType(array $json, PropertyInterface $prop } } - if (!empty($removedValues)) { + if (!empty($removedValues) && $generatorConfiguration->isOutputEnabled()) { $typeLabel = implode('|', $declaredTypes); $removedList = implode(', ', array_map( static fn($value): string => var_export($value, true), diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl index 4327df8c..57fa7ce5 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl @@ -1,5 +1,5 @@ /** - * {% if minPropertyValidator %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} + * {% if minPropertyValidator or schema.getCompositionValidatorKeys() or schema.getPostCompositionValidators() %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} */ private function _removeAdditionalProperty(string $key): bool { @@ -19,20 +19,70 @@ private function _removeAdditionalProperty(string $key): bool return false; } - {% if minPropertyValidator %} + {% if minPropertyValidator or schema.getCompositionValidatorKeys() or schema.getPostCompositionValidators() %} + // Snapshot every field a rejected removal must restore. Composition validators merge + // against _rawModelDataInput when they run, so the post-remove view has to live there + // — passing a candidate parameter alone gets re-merged. + $originalRawModelDataInput = $this->_rawModelDataInput; + + {% if schema.getCompositionValidatorKeys() %} + $originalCompositionEvaluations = $this->_compositionEvaluations ?? []; + $originalPropertyValidationState = $this->_propertyValidationState ?? []; + {% endif %} + {% if generatorConfiguration.collectErrors() %} $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% else %} + try { {% endif %} - if ({{ minPropertyValidator.getCheck() }}) { - {{ viewHelper.validationError(minPropertyValidator) }} - } + {% if minPropertyValidator %} + // minPropertyValidator's check expression compensates for the imminent removal + // (`count($this->_rawModelDataInput) - 1`), so it must run BEFORE the unset. + if ({{ minPropertyValidator.getCheck() }}) { + {{ viewHelper.validationError(minPropertyValidator) }} + } + {% endif %} + + unset($this->_rawModelDataInput[$key]); + + {% if schema.getCompositionValidatorKeys() %} + // Cross-state composition revalidation: removing a key may flip a branch + // (e.g. one whose required/minProperties/etc. depended on the key). Without + // this the cached _compositionEvaluations stay tied to the pre-removal + // state and the post-composition unevaluated check would miss orphans. + {% foreach schema.getCompositionValidatorKeys() as compositionValidatorKey %} + $this->_validateComposition_{{ compositionValidatorKey }}($this->_rawModelDataInput); + {% endforeach %} + {% endif %} + + {% if schema.getPostCompositionValidators() %} + $this->_executePostCompositionValidators($this->_rawModelDataInput); + {% endif %} {% if generatorConfiguration.collectErrors() %} if (count($this->_errorRegistry->getErrors())) { + $this->_rawModelDataInput = $originalRawModelDataInput; + {% if schema.getCompositionValidatorKeys() %} + $this->_compositionEvaluations = $originalCompositionEvaluations; + $this->_propertyValidationState = $originalPropertyValidationState; + {% endif %} + throw $this->_errorRegistry; } + {% else %} + } catch (\Throwable $rejection) { + $this->_rawModelDataInput = $originalRawModelDataInput; + {% if schema.getCompositionValidatorKeys() %} + $this->_compositionEvaluations = $originalCompositionEvaluations; + $this->_propertyValidationState = $originalPropertyValidationState; + {% endif %} + + throw $rejection; + } {% endif %} + {% else %} + unset($this->_rawModelDataInput[$key]); {% endif %} if ($inPatternProperties) { @@ -41,8 +91,6 @@ private function _removeAdditionalProperty(string $key): bool } } - unset($this->_rawModelDataInput[$key]); - if ($inAdditionalProperties) { unset($this->_additionalProperties[$key]); } diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl index ddde0e8e..b65d78fa 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl @@ -1,6 +1,6 @@ /** * {% if hasObjectProperties %}@throws RegularPropertyAsAdditionalPropertyException - * {% endif %}{% if schema.getBaseValidators() %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} + * {% endif %}{% if schema.getBaseValidators() or schema.getPostCompositionValidators() %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} */ private function _setAdditionalProperty( string $key, @@ -18,21 +18,44 @@ private function _setAdditionalProperty( {% if validationProperty %}{{ schemaHookResolver.resolveSetterBeforeValidationHook(validationProperty) }}{% endif %} - {% if schema.getBaseValidators() %} + {% if schema.getBaseValidators() or schema.getPostCompositionValidators() %} + // Snapshot the _additionalProperties bucket so any base-validator write or post- + // composition rejection can be unwound. + $rollbackAdditionalProperties = $this->_additionalProperties; + {% if generatorConfiguration.collectErrors() %} $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% else %} + try { {% endif %} - $addedProperty = [$key => $value]; - $this->_executeBaseValidators($addedProperty); + {% if schema.getBaseValidators() %} + $addedProperty = [$key => $value]; + $this->_executeBaseValidators($addedProperty); + {% else %} + $this->_additionalProperties[$key] = $value; + {% endif %} + + {% if schema.getPostCompositionValidators() %} + // Cross-state revalidation: feed the candidate raw input view to the post- + // composition phase so unevaluatedProperties sees the would-be state. + $candidateRawModelDataInput = $this->_rawModelDataInput; + $candidateRawModelDataInput[$key] = $value; + $this->_executePostCompositionValidators($candidateRawModelDataInput); + {% endif %} {% if generatorConfiguration.collectErrors() %} if (count($this->_errorRegistry->getErrors())) { + $this->_additionalProperties = $rollbackAdditionalProperties; throw $this->_errorRegistry; } + {% else %} + } catch (\Throwable $rejection) { + $this->_additionalProperties = $rollbackAdditionalProperties; + throw $rejection; + } {% endif %} {% else %} - // No base validators means no pattern properties either — value goes directly to _additionalProperties. $this->_additionalProperties[$key] = $value; {% endif %} diff --git a/tests/AbstractPHPModelGeneratorTestCase.php b/tests/AbstractPHPModelGeneratorTestCase.php index 4f86c549..9d3193fb 100644 --- a/tests/AbstractPHPModelGeneratorTestCase.php +++ b/tests/AbstractPHPModelGeneratorTestCase.php @@ -186,9 +186,11 @@ protected function generateClass( string $schemaProviderClass = RecursiveDirectoryProvider::class, ): string { $generatorConfiguration = clone ( - $generatorConfiguration ?? (new GeneratorConfiguration())->setCollectErrors(false) + $generatorConfiguration ?? (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setOutputEnabled(false) ); - $generatorConfiguration->setImplicitNull($implicitNull)->setOutputEnabled(false); + $generatorConfiguration->setImplicitNull($implicitNull); $draft = DraftRunContext::getDraftForDataName(static::class, $this->name(), (string) $this->dataName()); if ($draft !== null) { diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index 6f45f620..2d21f4af 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -4,8 +4,11 @@ namespace PHPModelGenerator\Tests\Basic; +use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\Object\InvalidUnevaluatedPropertiesException; +use PHPModelGenerator\Exception\Object\RequiredValueException; use PHPModelGenerator\Exception\Object\UnevaluatedPropertiesException; +use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use PHPModelGenerator\Tests\Support\ApplicableDrafts; use PHPModelGenerator\Tests\Support\JsonSchemaDraft; @@ -182,4 +185,163 @@ public function testAdditionalPropertiesShortCircuitsUnevaluatedCheck(): void $instance->meta()->rawInput(), ); } + + /** + * Required and unevaluated both fire when a required property is missing AND an undeclared + * extra is present. Two passes on the same generated schema: + * + * - Direct-exception mode: the constructor runs _executeBaseValidators() → + * _executePostCompositionValidators() → per-property processing in that order. The + * unevaluated check lives in the post-composition phase and surfaces first because the + * required check is part of per-property processing, which runs last. Documents the + * actual surfacing order so a future refactor that moves required earlier surfaces as + * a deliberate change instead of a silent regression. + * + * - Collect-errors mode: both errors accumulate in the registry. Asserts each + * exception's class identity and its full message so the registry's contents are + * pinned to the spec-relevant identifiers. + */ + public function testRequiredAndUnevaluatedBothFireWhenBothAreViolated(): void + { + // Direct-exception mode. + $directClassName = $this->generateClassFromFile('RequiredPropertyFailsFirst.json'); + + try { + new $directClassName(['extra' => 'value']); + $this->fail('Expected the unevaluated check to surface in direct-exception mode'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[extra\]$/', + $exception->getMessage(), + ); + $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); + } + + // Collect-errors mode — both errors must land in the registry. + $collectClassName = $this->generateClassFromFile( + 'RequiredPropertyFailsFirst.json', + (new GeneratorConfiguration())->setCollectErrors(true), + ); + + try { + new $collectClassName(['extra' => 'value']); + $this->fail('Expected the error registry to throw in collect-errors mode'); + } catch (ErrorRegistryException $registry) { + $errors = $registry->getErrors(); + + $requiredErrors = array_values(array_filter( + $errors, + static fn(\Throwable $error): bool => $error instanceof RequiredValueException, + )); + $unevaluatedErrors = array_values(array_filter( + $errors, + static fn(\Throwable $error): bool => $error instanceof UnevaluatedPropertiesException, + )); + + $this->assertCount(1, $requiredErrors, 'expected one RequiredValueException'); + $this->assertSame('Missing required value for name', $requiredErrors[0]->getMessage()); + + $this->assertCount(1, $unevaluatedErrors, 'expected one UnevaluatedPropertiesException'); + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[extra\]$/', + $unevaluatedErrors[0]->getMessage(), + ); + } + } + + /** + * The JSON Schema spec defines `default` as annotation-only: the keyword does not modify + * the instance during validation. Generators that materialise the default into a PHP field + * after validation must keep validation operating on the JSON instance view — otherwise a + * declared-but-absent property with a default would erroneously appear as evaluated to an + * enclosing `unevaluatedProperties` keyword, and worse, leak into `meta()->rawInput()` as + * a key the caller never supplied. + * + * Two assertions on one generated class: construction with the property absent succeeds and + * `meta()->rawInput()` reflects only what the caller passed in; the property getter + * returns the default value so the PHP field side of the contract is also intact. + */ + public function testDefaultValueIsNotPartOfEvaluatedSet(): void + { + $className = $this->generateClassFromFile('DefaultsNotEvaluated.json'); + $instance = new $className(['name' => 'Alice']); + + // The instance view used by validation never includes `timeout`, so + // unevaluatedProperties: false accepts the construction. + $this->assertSame(['name' => 'Alice'], $instance->meta()->rawInput()); + + // The PHP property side still surfaces the materialised default — the spec only forbids + // the default from entering the JSON instance view, not the language-level field. + $this->assertSame(30, $instance->getTimeout()); + } + + /** + * Empty `allOf: []` is spec-legal — the schema's existing + * `AbstractCompositionValidatorFactory::warnIfEmpty()` mechanism emits a warning at code- + * generation time when output is enabled, but the schema still compiles and runs. With no + * composition branches to contribute evaluated keys, the outer unevaluatedProperties: + * false sees only the local `properties` declarations. + * + * Two scenarios on the same generated class: + * - declared key accepted on its own — assertion on `meta()->rawInput()`; + * - any extra key rejected because the empty composition cannot claim it for the + * accumulator — assertions on the exception message and the offending key list. + */ + public function testEmptyAllOfStillEnforcesUnevaluatedAtOuterLevel(): void + { + $className = $this->generateClassFromFile('EmptyAllOf.json'); + + $accepted = new $className(['name' => 'Alice']); + $this->assertSame(['name' => 'Alice'], $accepted->meta()->rawInput()); + + try { + new $className(['name' => 'Alice', 'extra' => 'value']); + $this->fail('Empty allOf must not rescue extras from unevaluatedProperties: false'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[extra\]$/', + $exception->getMessage(), + ); + $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); + } + } + + /** + * A composition branch that declares its own `additionalProperties: {schema}` contributes + * the keys it validated against that schema to the outer accumulator. Two assertions on the + * same generated class: + * - `{kind: "X", extra: 1}` exercises the success path: branch 0's additionalProperties + * validates `extra` against `type: integer` and credits `extra` to its evaluated set; + * the outer unevaluatedProperties: false sees `extra` as evaluated and accepts. + * - `{kind: "X", extra: "bar"}` exercises the failure path: branch 0's additionalProperties + * rejects `extra`'s value, so branch 0 records `success: false` and contributes no + * evaluated keys. Branch 1 still succeeds (no constraint on extras at all means it makes + * no claim either) so anyOf passes — but the outer accumulator no longer credits `extra` + * and unevaluatedProperties: false rejects. + * + * The failure surface here is the outer unevaluated check, not the branch-internal + * additionalProperties — that internal failure is silently swallowed by the branch's + * try/catch, which is exactly the behaviour the per-key validity signal preserves. + */ + public function testBranchLevelAdditionalPropertiesFeedsEvaluatedSetThroughComposition(): void + { + $className = $this->generateClassFromFile('BranchLevelAdditionalProperties.json'); + + $accepted = new $className(['kind' => 'X', 'extra' => 1]); + $this->assertSame(['kind' => 'X', 'extra' => 1], $accepted->meta()->rawInput()); + + try { + new $className(['kind' => 'X', 'extra' => 'bar']); + $this->fail( + 'Branch-level additionalProperties value failure must orphan the key so the ' + . 'outer unevaluated check fires', + ); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[extra\]$/', + $exception->getMessage(), + ); + $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); + } + } } diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php index 55ff0004..7dcabb22 100644 --- a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -4,11 +4,14 @@ namespace PHPModelGenerator\Tests\PostProcessor; -use PHPModelGenerator\Exception\ValidationException; +use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\Object\RegularPropertyAsUnevaluatedPropertyException; +use PHPModelGenerator\Exception\Object\UnevaluatedPropertiesException; +use PHPModelGenerator\Exception\ValidationException; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\ModelGenerator; use PHPModelGenerator\SchemaProcessor\PostProcessor\AdditionalPropertiesAccessorPostProcessor; +use PHPModelGenerator\SchemaProcessor\PostProcessor\PatternPropertiesAccessorPostProcessor; use PHPModelGenerator\SchemaProcessor\PostProcessor\UnevaluatedPropertiesAccessorPostProcessor; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use PHPModelGenerator\Tests\Support\ApplicableDrafts; @@ -384,6 +387,354 @@ public function testAdditionalTrueClaimsExtrasAndUnevaluatedBucketRemainsEmpty() ); } + /** + * Schema without `additionalProperties` + outer `unevaluatedProperties: false`. The user + * opts the additionalProperties accessor in via `addForModelsWithoutAdditionalPropertiesDefinition` + * and tries to `$model->additionalProperties()->set(...)` a key no sibling claims. The + * shim's cross-state revalidation runs the post-composition phase against a candidate + * raw input view; the enclosing unevaluatedProperties: false rejects the new key, and + * `_additionalProperties` rolls back to its pre-call state. + */ + public function testAdditionalAccessorSetIsRejectedWhenUnevaluatedFalseOrphansTheKey(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor( + new AdditionalPropertiesAccessorPostProcessor( + addForModelsWithoutAdditionalPropertiesDefinition: true, + ), + ); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + + $className = $this->generateClassFromFile( + 'NoAdditionalUnevaluatedFalse.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + // Construction with an orphan key must already be rejected by unevaluatedProperties: + // false — the accessor's set() path mirrors this rejection at runtime, so the two + // entry points (constructor and accessor) report the same orphan via the same + // exception class. + try { + new $className(['name' => 'Alice', 'foo' => 'orphan']); + $this->fail('Expected construction to reject the orphan key'); + } catch (UnevaluatedPropertiesException $constructorException) { + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[foo\]$/', + $constructorException->getMessage(), + ); + $this->assertSame(['foo'], $constructorException->getUnevaluatedProperties()); + } + + $object = new $className(['name' => 'Alice']); + + $this->assertTrue(method_exists($object, 'additionalProperties')); + // unevaluatedProperties: false → no accessor (nothing to expose; pure assertion). + $this->assertFalse(method_exists($object, 'unevaluatedProperties')); + + $additionalAccessor = $object->additionalProperties(); + + try { + $additionalAccessor->set('foo', 'orphan'); + $this->fail('Expected unevaluatedProperties: false to reject the orphan key'); + } catch (UnevaluatedPropertiesException $setterException) { + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[foo\]$/', + $setterException->getMessage(), + ); + $this->assertSame(['foo'], $setterException->getUnevaluatedProperties()); + } + + // Rollback discipline: neither the additional-properties bucket nor the raw input + // records the failed set. + $this->assertSame([], $additionalAccessor->getAll()); + $this->assertSame(['name' => 'Alice'], $object->meta()->rawInput()); + } + + /** + * `additionalProperties: true` + sibling `unevaluatedProperties: {type: integer}` is a + * dead-code cell in the §4.1 matrix: additionalProperties claims every extra at runtime, + * so the unevaluated validator and accessor are both suppressed at codegen. The user can + * therefore call `$model->additionalProperties()->set(...)` with a value of any type — + * the unevaluated schema's `type: integer` constraint never runs, and a string value + * lands unobserved. + * + * The factory also emits a generation-time warning via the standard `echo` channel so the + * developer gets a hint that the unevaluatedProperties keyword cannot affect validation at + * this schema level. The assertion below pins both the warning text and the offending + * class name. + */ + public function testAdditionalAccessorSetUnderSuppressedUnevaluatedIsPermitted(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new AdditionalPropertiesAccessorPostProcessor()); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + + $this->expectOutputRegex( + '/Warning: unevaluatedProperties on \S+ is dead code — sibling additionalProperties ' + . 'already claims every extra key/', + ); + + $className = $this->generateClassFromFile( + 'AdditionalTrueWithUnevaluatedSchema.json', + (new GeneratorConfiguration())->setImmutable(false)->setOutputEnabled(true), + ); + + $object = new $className(['name' => 'Alice']); + + // No unevaluated accessor — the dead-cell suppression applied at codegen. + $this->assertFalse(method_exists($object, 'unevaluatedProperties')); + + // additionalProperties.set() lands a string even though the suppressed + // unevaluatedProperties: {type: integer} would have rejected it. + $object->additionalProperties()->set('foo', 'hello'); + $this->assertSame('hello', $object->additionalProperties()->get('foo')); + $this->assertSame(['name' => 'Alice', 'foo' => 'hello'], $object->meta()->rawInput()); + } + + /** + * Pattern-matched keys land in `_patternProperties`; truly-unevaluated keys land in + * `_unevaluatedProperties`. The two buckets are non-overlapping by construction — the + * pattern keyword runs before the unevaluated check and the unevaluated rebuild credits + * pattern-matched keys as already-evaluated. + * + * The shim's runtime guard rejects keys matching a local `patternProperties` pattern when + * the user tries to route them through `unevaluatedProperties()->set()` — they belong to a + * different contract (the pattern's type schema) and must go through the pattern accessor. + */ + public function testPatternAndUnevaluatedAccessorsExposeNonOverlappingBuckets(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new PatternPropertiesAccessorPostProcessor()); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + + $className = $this->generateClassFromFile( + 'PatternAndUnevaluatedCoexist.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(['name' => 'Alice', 'x_foo' => 'pattern-value', 'count' => 42]); + + // pattern-matching key landed in _patternProperties, not in _unevaluatedProperties. + // The pattern accessor's `get()` returns the full key→value map for the pattern. + $patternAccessor = $object->patternProperties(); + $this->assertSame(['x_foo' => 'pattern-value'], $patternAccessor->get('^x_')); + + // non-matching key landed in _unevaluatedProperties only. + $unevaluatedAccessor = $object->unevaluatedProperties(); + $this->assertSame(['count' => 42], $unevaluatedAccessor->getAll()); + $this->assertNull($unevaluatedAccessor->get('x_foo')); + + // Attempting to route a pattern-matching key through the unevaluated accessor is rejected + // by the shim guard. + $this->expectException(RegularPropertyAsUnevaluatedPropertyException::class); + $unevaluatedAccessor->set('x_bar', 99); + } + + /** + * `remove()` walks both the backing _unevaluatedProperties array and the raw model data so + * a subsequent `getAll()` reports the post-removal state and `meta()->rawInput()` no longer + * carries the removed key. A second `remove()` on the same key is a no-op and returns + * false. + */ + public function testRemoveDeletesKeyFromBackingFieldAndRawInput(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(['name' => 'Alice', 'count' => 5, 'limit' => 7]); + $accessor = $object->unevaluatedProperties(); + + $this->assertTrue($accessor->remove('count')); + + // The removed key is gone from both views; the surviving key is intact. + $this->assertSame(['limit' => 7], $accessor->getAll()); + $this->assertNull($accessor->get('count')); + $this->assertSame(['name' => 'Alice', 'limit' => 7], $object->meta()->rawInput()); + + // Second removal of the same key is a no-op. + $this->assertFalse($accessor->remove('count')); + $this->assertSame(['limit' => 7], $accessor->getAll()); + } + + /** + * A key claimed via the unevaluated accessor stays in `_unevaluatedProperties` even after + * a later mutation makes a composition branch claim the same key. The accumulator-rebuild + * model treats the bucket as a write-once view from the accessor's perspective: keys move + * in via `set()` and `remove()`, never via background reshuffles when an enclosing branch + * starts/stops covering them. + * + * The shim guard rejects keys statically declared inside any composition branch's + * `properties` or `patternProperties`, so the sibling cover in this test comes via a + * branch's `additionalProperties: {type: integer}` — a *dynamic* claim that the guard + * cannot anticipate at codegen. + * + * Sequence on the same generated class: + * - construct `{kind: "X"}` → branch 0 succeeds, no extras, _unevaluatedProperties empty + * - accessor.set('foo', 5) → routes through the shim (guard only sees `kind` in + * composition branches, never `foo`); foo lands in _unevaluatedProperties + * - setKind('Y') → branch 0 fails, branch 1 succeeds and its additionalProperties claims + * `foo` for the accumulator; the unevaluated validator's foreach finds no leftover + * keys to validate and therefore does not touch _unevaluatedProperties + * - assert: `foo` is still in _unevaluatedProperties, exposed by getAll() + * + * Documents the corner the plan calls out: claimed-via-unevaluated values are not + * retroactively reshuffled when a later mutation changes which sibling covers them. + */ + public function testClaimedViaUnevaluatedKeyPersistsAfterSiblingLaterCovers(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'KindDiscriminatorTypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['kind' => 'X']); + $accessor = $object->unevaluatedProperties(); + + $accessor->set('foo', 5); + $this->assertSame(['foo' => 5], $accessor->getAll()); + + // Flip the discriminator: branch 1 now succeeds (kind=Y + foo:5 satisfies its required + // list) and its `properties` declaration credits `foo` to the accumulator. The + // unevaluated validator finds no leftover keys. + $object->setKind('Y'); + + // Still surfaced via the unevaluated accessor — the key is not silently moved into a + // sibling-managed bucket on revalidation. + $this->assertSame(['foo' => 5], $accessor->getAll()); + $this->assertSame(5, $accessor->get('foo')); + $this->assertSame(['kind' => 'Y', 'foo' => 5], $object->meta()->rawInput()); + } + + /** + * Collect-errors mode for `_setAdditionalProperty`: the shim shares a single + * `_errorRegistry` across the base-validator and post-composition phases and throws once + * at the end, so errors from both phases land in the same registry. A patternProperties + * type failure (base phase) combined with an unevaluatedProperties orphan (post-composition + * phase) must both surface. + * + * Schema: `properties: {name}` + `patternProperties: {"^x_": {"type": "integer"}}` + + * `unevaluatedProperties: false`; additional accessor enabled via + * `addForModelsWithoutAdditionalPropertiesDefinition`. + * + * Setting `x_foo` to a string: + * - base phase: pattern matches, value fails `type: integer` → InvalidPatternPropertiesException + * in the registry. PatternProperties.phptpl rolls `_patternProperties` back so the + * storage no longer records `x_foo` for this call's value. + * - post-composition phase: collectUnevaluatedKeys correlates the pattern claim against + * `_patternProperties` to honour per-key validity (decision 0.5); the rolled-back + * storage means `x_foo` is *not* credited as evaluated, and unevaluatedProperties: false + * rejects → UnevaluatedPropertiesException in the registry. + * + * Both exceptions are then surfaced through a single ErrorRegistryException. + */ + public function testSetCollectsPatternAndUnevaluatedErrorsTogether(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor( + new AdditionalPropertiesAccessorPostProcessor( + addForModelsWithoutAdditionalPropertiesDefinition: true, + ), + ); + }; + + $className = $this->generateClassFromFile( + 'PatternWithUnevaluatedFalse.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(true), + ); + + $object = new $className(['name' => 'Alice', 'x_foo' => 5]); + $additionalAccessor = $object->additionalProperties(); + + try { + $additionalAccessor->set('x_foo', 'bad-string'); + $this->fail('Expected ErrorRegistryException with pattern + unevaluated errors'); + } catch (ErrorRegistryException $registry) { + $this->assertSame( + <<getMessage(), + ); + } + + // Rollback discipline: _patternProperties restored to the pre-call value; + // _rawModelDataInput unchanged. + $this->assertSame(['name' => 'Alice', 'x_foo' => 5], $object->meta()->rawInput()); + } + + /** + * Collect-errors mode for `_removeAdditionalProperty`: the shim's `minPropertyValidator` + * inline check, the composition revalidation (so `_compositionEvaluations` reflects the + * post-removal state), and the post-composition revalidation share a single + * `_errorRegistry` and a single throw at the end. Removing a key can flip a composition + * branch and orphan a key that the previous branch had claimed; without composition + * revalidation the cache stays stale and the unevaluated check misses the orphan. + * + * Schema: `properties: {kind}` + `patternProperties: {"^p_": {"type": "integer"}}` + + * `minProperties: 3` + an anyOf where branch 0 requires `p_foo` and claims `q_marker` and + * branch 1 succeeds whenever `kind` is `"X"` and claims nothing. With construction + * `{kind: "X", p_foo: 1, q_marker: 7}`, both anyOf branches succeed and `q_marker` is + * credited by branch 0's declared-property list; the model is valid. + * + * Removing `p_foo`: + * - minPropertyValidator: candidate count drops to 2 < 3 → MinPropertiesException. + * - composition revalidation on candidate: branch 0 fails (missing p_foo); branch 1 + * still succeeds (kind=X); anyOf as a whole still passes — but `q_marker` is no + * longer in any successful branch's claimed set. + * - post-composition unevaluatedProperties: false: `q_marker` is now an orphan → + * UnevaluatedPropertiesException. + * + * Both errors land in the registry. On the throw, the model state is rolled back so the + * caller still sees the pre-removal raw input and the pre-removal pattern-bucket. + */ + public function testRemoveCollectsMinPropertyAndUnevaluatedErrorsTogether(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor( + new AdditionalPropertiesAccessorPostProcessor( + addForModelsWithoutAdditionalPropertiesDefinition: true, + ), + ); + }; + + $className = $this->generateClassFromFile( + 'BranchFlipOnRemove.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(true), + ); + + $object = new $className(['kind' => 'X', 'p_foo' => 1, 'q_marker' => 7]); + $additionalAccessor = $object->additionalProperties(); + + try { + $additionalAccessor->remove('p_foo'); + $this->fail('Expected ErrorRegistryException with min-property + unevaluated errors'); + } catch (ErrorRegistryException $registry) { + $this->assertSame( + <<getMessage(), + ); + } + + // The rejected removal must roll the model state back to its pre-call values. + $this->assertSame( + ['kind' => 'X', 'p_foo' => 1, 'q_marker' => 7], + $object->meta()->rawInput(), + ); + } + /** * Runtime coexistence: additionalProperties: false rejects every extra at construction * time even though the user enabled both accessor post processors. The unevaluated diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json new file mode 100644 index 00000000..b190b6f1 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json @@ -0,0 +1,35 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "patternProperties": { + "^p_": { + "type": "integer" + } + }, + "minProperties": 3, + "anyOf": [ + { + "minProperties": 3, + "properties": { + "kind": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } + }, + { + "properties": { + "kind": { + "const": "X" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/KindDiscriminatorTypedExtras.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/KindDiscriminatorTypedExtras.json new file mode 100644 index 00000000..b1cffbf6 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/KindDiscriminatorTypedExtras.json @@ -0,0 +1,33 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "required": [ + "kind" + ], + "oneOf": [ + { + "properties": { + "kind": { + "const": "X" + } + } + }, + { + "properties": { + "kind": { + "const": "Y" + } + }, + "additionalProperties": { + "type": "integer" + } + } + ], + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/MinPropertiesAndUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/MinPropertiesAndUnevaluatedSchema.json new file mode 100644 index 00000000..e9b3b894 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/MinPropertiesAndUnevaluatedSchema.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "minProperties": 3, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NoAdditionalUnevaluatedFalse.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NoAdditionalUnevaluatedFalse.json new file mode 100644 index 00000000..59b98669 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NoAdditionalUnevaluatedFalse.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternAndUnevaluatedCoexist.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternAndUnevaluatedCoexist.json new file mode 100644 index 00000000..eda63e50 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternAndUnevaluatedCoexist.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "patternProperties": { + "^x_": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternWithUnevaluatedFalse.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternWithUnevaluatedFalse.json new file mode 100644 index 00000000..f6c201e7 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternWithUnevaluatedFalse.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "patternProperties": { + "^x_": { + "type": "integer" + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchLevelAdditionalProperties.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchLevelAdditionalProperties.json new file mode 100644 index 00000000..a1fdd446 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchLevelAdditionalProperties.json @@ -0,0 +1,29 @@ +{ + "type": "object", + "anyOf": [ + { + "properties": { + "kind": { + "const": "X" + } + }, + "required": [ + "kind" + ], + "additionalProperties": { + "type": "integer" + } + }, + { + "properties": { + "kind": { + "type": "string" + } + }, + "required": [ + "kind" + ] + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/DefaultsNotEvaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/DefaultsNotEvaluated.json new file mode 100644 index 00000000..277087be --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/DefaultsNotEvaluated.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "timeout": { + "type": "integer", + "default": 30 + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/EmptyAllOf.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/EmptyAllOf.json new file mode 100644 index 00000000..7d1a67a0 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/EmptyAllOf.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/RequiredPropertyFailsFirst.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/RequiredPropertyFailsFirst.json new file mode 100644 index 00000000..978166e4 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/RequiredPropertyFailsFirst.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "unevaluatedProperties": false +} From 180b7e0fdb688edc5563f1442bcf3618eea088a9 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 18 Jun 2026 11:22:20 +0200 Subject: [PATCH 09/51] Propagate branch-level unevaluatedProperties claims to outer accumulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A composition branch with its own unevaluatedProperties:{schema} now contributes the keys it evaluates against that schema to an enclosing unevaluatedProperties through the same nested-class read-through the trait already uses for declared properties. UnevaluatedPropertiesPostProcessor adds an internal _evaluatedPropertyKeys field to any schema carrying the schema-form UnevaluatedPropertiesValidator. UnevaluatedProperties.phptpl writes the key into this field on per-key validation success, with snapshot/rollback mirroring the existing _unevaluatedProperties discipline. Nested branch classes gain an _getEvaluatedProperties() method (underscore prefix + #[Internal]) returning the union of declared properties present in the raw input and keys recorded in _evaluatedPropertyKeys. The underscore sidesteps a name collision with a user-declared evaluatedProperties property's auto-generated getter and the attribute documents the role. testNestedUnevaluatedInBranchPropagatesClaimsToOuter runs in collect-errors mode so a single ErrorRegistryException carries the full chain: the outer allOf failure, the nested class's invalid-unevaluated-property cause, and the outer unevaluatedProperties: false rejecting the orphaned foo and bar. The assertion uses heredoc to compare the full message, with an inline comment explaining why foo appears as an orphan despite passing the branch's local properties.foo validator — failed composition branches contribute no annotations to the outer accumulator under JSON Schema 2019-09's applicator rules. Co-Authored-By: Claude Opus 4.7 --- .../UnevaluatedPropertiesPostProcessor.php | 67 ++++++++++++++++--- .../Validator/UnevaluatedProperties.phptpl | 20 ++++-- .../UnevaluatedPropertiesValidatorTest.php | 65 ++++++++++++++++++ ...edUnevaluatedInBranchPropagatesClaims.json | 16 +++++ 4 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/NestedUnevaluatedInBranchPropagatesClaims.json diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php index d3c076f9..eee50829 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -12,6 +12,7 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; +use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; use PHPModelGenerator\Traits\CompositionEvaluationTrait; @@ -48,8 +49,14 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu return; } + // A schema with a non-false `unevaluatedProperties` validator tracks each key the + // validator successfully evaluates in `_evaluatedPropertyKeys`. That field is read + // by `_getEvaluatedProperties()` on nested branch classes so an enclosing + // `unevaluatedProperties` sees those keys as evaluated. + $this->addEvaluatedPropertyKeysField($schema); + // The nested branch schemas may already be queued — adding the method here, before any - // render() runs, ensures every branch class carries getEvaluatedProperties() regardless + // render() runs, ensures every branch class carries _getEvaluatedProperties() regardless // of whether the outer or inner schema was processed first. RenderQueue::execute runs // process() over every job before render() begins. $this->addGetEvaluatedPropertiesToNestedBranchSchemas($schema); @@ -81,9 +88,39 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu } /** - * For each composition branch that produces a nested object class, adds a - * getEvaluatedProperties() method so the unevaluatedProperties validator can query - * which properties the nested class evaluated. + * Adds the `_evaluatedPropertyKeys` collection field to a schema that carries the + * schema-form `UnevaluatedPropertiesValidator`. Nested branch schemas reach this code + * through their own `process()` call (RenderQueue calls each job's processors), so no + * recursion across branches is required here. + */ + private function addEvaluatedPropertyKeysField(Schema $schema): void + { + foreach ($schema->getPostCompositionValidators() as $postCompositionValidator) { + if ($postCompositionValidator instanceof UnevaluatedPropertiesValidator) { + $schema->addProperty( + (new Property( + 'evaluatedPropertyKeys', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + )) + ->setInternal(true) + ->setDefaultValue([]), + ); + + return; + } + } + } + + /** + * For each composition branch that produces a nested object class, adds an internal + * `_getEvaluatedProperties()` method the enclosing schema's unevaluatedProperties + * validator queries to learn which keys the nested class evaluated. + * + * The method name uses an underscore prefix so it cannot collide with a user-declared + * schema property called `evaluatedProperties` (whose generated getter would be + * `getEvaluatedProperties()` without the underscore) and so its internal-only role is + * visible at the call site. */ private function addGetEvaluatedPropertiesToNestedBranchSchemas(Schema $schema): void { @@ -95,12 +132,12 @@ private function addGetEvaluatedPropertiesToNestedBranchSchemas(Schema $schema): foreach ($baseValidator->getComposedProperties() as $composedProperty) { $nestedSchema = $composedProperty->getNestedSchema(); - if ($nestedSchema === null || $nestedSchema->hasMethod('getEvaluatedProperties')) { + if ($nestedSchema === null || $nestedSchema->hasMethod('_getEvaluatedProperties')) { continue; } $nestedSchema->addMethod( - 'getEvaluatedProperties', + '_getEvaluatedProperties', $this->buildGetEvaluatedPropertiesMethod($nestedSchema), ); } @@ -108,10 +145,10 @@ private function addGetEvaluatedPropertiesToNestedBranchSchemas(Schema $schema): } /** - * Builds a MethodInterface that emits getEvaluatedProperties() for the given nested schema. - * - * The method returns all declared property names (from the nested schema's `properties` - * keyword) that are present in the instance's raw model data at the time of the call. + * Builds a MethodInterface that emits `_getEvaluatedProperties()` for the given nested + * schema. The method returns the union of declared property names present in the + * instance's raw model data and the keys recorded in `_evaluatedPropertyKeys` (populated + * by the nested schema's own unevaluatedProperties validator). */ private function buildGetEvaluatedPropertiesMethod(Schema $nestedSchema): MethodInterface { @@ -132,7 +169,8 @@ public function getCode(): string return sprintf( ' - public function getEvaluatedProperties(): array + #[Internal] + public function _getEvaluatedProperties(): array { $evaluated = []; foreach (%s as $propName) { @@ -140,6 +178,13 @@ public function getEvaluatedProperties(): array $evaluated[$propName] = true; } } + // Keys evaluated by this schema\'s own unevaluatedProperties: {schema} + // validator are tracked so an enclosing schema can see them. + if (property_exists($this, "_evaluatedPropertyKeys")) { + foreach ($this->_evaluatedPropertyKeys as $propName => $_) { + $evaluated[$propName] = true; + } + } return array_keys($evaluated); }', var_export($declaredPropertyNames, true), diff --git a/src/Templates/Validator/UnevaluatedProperties.phptpl b/src/Templates/Validator/UnevaluatedProperties.phptpl index ebb9e0dc..901626b5 100644 --- a/src/Templates/Validator/UnevaluatedProperties.phptpl +++ b/src/Templates/Validator/UnevaluatedProperties.phptpl @@ -3,8 +3,9 @@ $originalErrorRegistry = $this->_errorRegistry; {% endif %} {% if collectUnevaluatedProperties %} - $rollbackValues = $this->_unevaluatedProperties; + $rollbackUnevaluatedProperties = $this->_unevaluatedProperties; {% endif %} + $rollbackEvaluatedPropertyKeys = $this->_evaluatedPropertyKeys; $unevaluatedKeys = $this->collectUnevaluatedKeys( $modelData, @@ -30,9 +31,15 @@ {% if generatorConfiguration.collectErrors() %} if ($this->_errorRegistry->getErrors()) { $invalidProperties[$propertyKey] = $this->_errorRegistry->getErrors(); + + continue; } {% endif %} + // The key validated against the unevaluatedProperties subschema — record it so an + // enclosing schema's unevaluatedProperties accumulator can credit it. + $this->_evaluatedPropertyKeys[$propertyKey] = true; + {% if collectUnevaluatedProperties %} $this->_unevaluatedProperties[$propertyKey] = $value; {% endif %} @@ -47,11 +54,12 @@ $this->_errorRegistry = $originalErrorRegistry; {% endif %} - {% if collectUnevaluatedProperties %} - if (!empty($invalidProperties)) { - $this->_unevaluatedProperties = $rollbackValues; - } - {% endif %} + if (!empty($invalidProperties)) { + {% if collectUnevaluatedProperties %} + $this->_unevaluatedProperties = $rollbackUnevaluatedProperties; + {% endif %} + $this->_evaluatedPropertyKeys = $rollbackEvaluatedPropertyKeys; + } return !empty($invalidProperties); })() diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index 2d21f4af..672bee78 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -306,6 +306,71 @@ public function testEmptyAllOfStillEnforcesUnevaluatedAtOuterLevel(): void } } + /** + * A composition branch that declares its own `unevaluatedProperties: {schema}` records + * the keys it evaluates against that schema. Those records must propagate to an + * outer `unevaluatedProperties: false` so the outer treats them as already evaluated. + * + * Two scenarios on the same generated class (collect-errors mode so the registry + * surfaces both the inner failure cause and the outer-level orphans in one message): + * - `{foo: "hi", bar: 5}` exercises the success path. The branch's `properties` + * declares `foo`. The branch's `unevaluatedProperties: {integer}` validates `bar` + * (5 is integer). The outer `unevaluatedProperties: false` sees both keys evaluated + * through the branch's contribution and accepts. + * - `{foo: "hi", bar: "not-int"}` exercises the inner-rejection path. The branch's + * `unevaluatedProperties: {integer}` rejects `bar` ("not-int" fails the integer + * check); the allOf composition therefore matches zero elements; and the outer's + * `unevaluatedProperties: false` then sees both `foo` and `bar` as orphans because + * no successful branch claimed them. All three pieces appear in the registry's + * aggregated message. + */ + public function testNestedUnevaluatedInBranchPropagatesClaimsToOuter(): void + { + $className = $this->generateClassFromFile( + 'NestedUnevaluatedInBranchPropagatesClaims.json', + (new GeneratorConfiguration())->setCollectErrors(true), + ); + + $accepted = new $className(['foo' => 'hi', 'bar' => 5]); + $this->assertSame(['foo' => 'hi', 'bar' => 5], $accepted->meta()->rawInput()); + + // The branch is rendered as a nested class alongside the outer. Locate it by + // pattern-matching the file list so the assertion below can spell the full message. + $generationDir = dirname((new \ReflectionClass($className))->getFileName()); + $nestedFiles = array_values(array_filter( + glob($generationDir . DIRECTORY_SEPARATOR . '*.php'), + static fn(string $path): bool => str_contains(basename($path), $className . '_'), + )); + $nestedClassName = str_replace('.php', '', basename($nestedFiles[0])); + + try { + new $className(['foo' => 'hi', 'bar' => 'not-int']); + $this->fail('Inner unevaluatedProperties: {integer} must reject the non-integer extra'); + } catch (ErrorRegistryException $registry) { + // The registry surfaces the inner cause AND a separate outer-level orphan report + // for [foo, bar]. Both lines are correct under JSON Schema 2019-09's applicator + // rules: a composition branch that fails as a whole contributes no annotations, + // so even foo — whose individual value passed the branch's properties.foo + // validator — is treated as unevaluated at the outer level because the branch's + // claim never propagated. The two messages are complementary: the composition + // block reports why the branch failed, and the trailing line reports what the + // outer's unevaluatedProperties: false saw once it ran with no successful + // branches. + $this->assertSame( + <<getMessage(), + ); + } + } + /** * A composition branch that declares its own `additionalProperties: {schema}` contributes * the keys it validated against that schema to the outer accumulator. Two assertions on the diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/NestedUnevaluatedInBranchPropagatesClaims.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/NestedUnevaluatedInBranchPropagatesClaims.json new file mode 100644 index 00000000..92c32212 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/NestedUnevaluatedInBranchPropagatesClaims.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "allOf": [ + { + "properties": { + "foo": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "integer" + } + } + ], + "unevaluatedProperties": false +} From 7dda59f6359b436888ea2ac42466fd54d74f87ca Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 22 Jun 2026 17:07:37 +0200 Subject: [PATCH 10/51] Add unevaluatedItems validator and array-side activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces UnevaluatedItemsValidator and NoUnevaluatedItemsValidator on the array side, mirroring the property-side flavours. The validator runs at priority 101 on the array property's validator chain (after composition at 100), wrapping its check in is_array($value) so it short-circuits cleanly for union types like ["array", "null"]. Both flavours render through a new collectUnevaluatedIndices() trait method that reads \$this->_evaluatedItemIndices defensively (the field arrives in a later stage) and walks \$this->_compositionEvaluations slots filtered by 'kind' => 'array' so the property-side and array-side rebuilds cannot contaminate each other on mixed-type oneOf. UnevaluatedItemsValidatorFactory skips emission for absent and true keywords, throws a SchemaException for non-bool/non-object values, and warns through the existing echo channel for the three array-side dead-code shapes — items:false, items:{schema}, and additionalItems:false with tuple items — that make the keyword unreachable. The property-side factory gains the same SchemaException guard for non-bool/non-object unevaluatedProperties values (an oversight that the array-side test surfaced). Draft_2019_09 registers the factory on the array type. The unevaluated post processor's needsActivation walk now inspects each property's own JSON before recursing into its nested schema, so a schema like {type: object, properties: {tags: {type: array, unevaluatedItems: false}}} activates the outer object's evaluation tracking (array properties carry no nested schema, so the previous loop missed them). The production library gains UnevaluatedItemsException and InvalidUnevaluatedItemsException. The false-form message reports indices with the # prefix used by the rest of the array-side family (InvalidItemException, InvalidTupleException); the schema-form wrapper preserves the per-index inner exceptions verbatim, so consumers catching the wrapper can still surface the underlying InvalidTypeException reason. CLAUDE.md adds two enforcement rules. "Always review the diff against the repo rules before staging" prescribes a grep recipe for plan/stage/ phase/section references and applies symmetrically to coordinated production-library checkouts. "Pre-existing rule violations in touched files" requires sweeping every visible CLAUDE.md violation in any file edited as part of a task, with one escape hatch when cleanup would balloon into an unrelated refactor. UnevaluatedItemsValidatorTest covers the bare false and schema forms (per-index # notation), uniqueItems-precedence ordering, the four dead-code warning shapes via expectOutputRegex, and the SchemaException path for non-bool/non-object values on both keywords. The schema-form test asserts the full multi-line exception message via heredoc using two failing indices and pins getInvalidItems() to confirm each failing index's InvalidTypeException survives the wrapper. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 30 ++ src/Draft/Draft_2019_09.php | 4 +- .../AbstractUnevaluatedItemsValidator.php | 48 +++ .../UnevaluatedItemsValidatorFactory.php | 157 ++++++++++ .../UnevaluatedPropertiesValidatorFactory.php | 15 +- .../Validator/NoUnevaluatedItemsValidator.php | 30 ++ .../Validator/UnevaluatedItemsValidator.php | 87 ++++++ .../UnevaluatedPropertiesPostProcessor.php | 13 + .../Validator/NoUnevaluatedItems.phptpl | 5 + .../Validator/UnevaluatedItems.phptpl | 44 +++ tests/Basic/UnevaluatedItemsValidatorTest.php | 281 ++++++++++++++++++ .../InvalidUnevaluatedItemsType.json | 9 + .../InvalidUnevaluatedPropertiesType.json | 4 + .../ItemsFalseWithUnevaluatedFalse.json | 10 + .../ItemsSchemaWithUnevaluatedFalse.json | 12 + .../ItemsSchemaWithUnevaluatedSchema.json | 14 + .../NoOtherConstraintsFalse.json | 9 + .../NoOtherConstraintsSchema.json | 11 + ...leAdditionalFalseWithUnevaluatedFalse.json | 15 + .../UnevaluatedFalseWithUniqueItems.json | 10 + 20 files changed, 806 insertions(+), 2 deletions(-) create mode 100644 src/Model/Validator/AbstractUnevaluatedItemsValidator.php create mode 100644 src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php create mode 100644 src/Model/Validator/NoUnevaluatedItemsValidator.php create mode 100644 src/Model/Validator/UnevaluatedItemsValidator.php create mode 100644 src/Templates/Validator/NoUnevaluatedItems.phptpl create mode 100644 src/Templates/Validator/UnevaluatedItems.phptpl create mode 100644 tests/Basic/UnevaluatedItemsValidatorTest.php create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsType.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesType.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/ItemsFalseWithUnevaluatedFalse.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedFalse.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedSchema.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsFalse.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsSchema.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalFalseWithUnevaluatedFalse.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedFalseWithUniqueItems.json diff --git a/CLAUDE.md b/CLAUDE.md index 1b6eb178..df1bb57e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,6 +231,36 @@ After finishing an implementation task, always stage all relevant changed files Never add `.claude/` files (issues, topics, memory, etc.) to git unless the user explicitly asks. These are working notes for the session and must not appear in commits. +**Always review the diff against the repo rules before staging.** Before running `git add`, +inspect the full diff (`git diff` for unstaged work, plus `git diff --staged` afterwards) and +verify every change conforms to the rules in this file. In particular, run the recovery procedure +from the "No implementation-plan references in code" rule: grep the diff for `Stage `, `Phase `, +`decision `, `§`, and `#` followed by a number, and rewrite every match in source, test, or +prod-lib code (test data providers, DocBlocks, and inline comments included) before staging. +This review is *mandatory*, not optional — staging without it lets violations slip into commits. + +The same review applies to changes pushed to a coordinated production-library checkout: source +code in `php-json-schema-model-generator-production` is bound by the same rules as code in this +repo. Planning artefacts under `.claude/` are the only place plan references may live. + +### Pre-existing rule violations in touched files + +Whenever you edit, read, or otherwise touch a file as part of any task, sweep it for *all* +pre-existing violations of the rules in this file — implementation-plan references, single- +letter variables, leading-backslash class references, missing `use` imports, copy-pasted +docblocks, PHPCS errors visible in the local run, and anything else CLAUDE.md forbids — and +fix every one in the same change. Do not leave a known violation sitting just because it +predates your edit; "broken windows" is exactly how decay accumulates and the rule erodes. + +Scope: this is about files you *touch*, not a codebase-wide audit. If you edit a method, +scan the whole file (not just the surrounding lines) and fix everything visible. If +fixing the pre-existing violations would balloon the diff into an unrelated refactor, +flag it (and only then) before proceeding — that is the only escape hatch. Default is: +clean it up. + +The rule applies symmetrically to the production-library checkout when you edit anything +there. + ### 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. diff --git a/src/Draft/Draft_2019_09.php b/src/Draft/Draft_2019_09.php index 3e7e71fb..273a3aa3 100644 --- a/src/Draft/Draft_2019_09.php +++ b/src/Draft/Draft_2019_09.php @@ -5,6 +5,7 @@ namespace PHPModelGenerator\Draft; use PHPModelGenerator\Model\Validator\Factory\Arrays\ContainsValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Arrays\UnevaluatedItemsValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\Object\UnevaluatedPropertiesValidatorFactory; class Draft_2019_09 extends Draft_07 @@ -14,7 +15,8 @@ public function getDefinition(): DraftBuilder $builder = parent::getDefinition(); $builder->getType('array') - ->addValidator('contains', new ContainsValidatorFactory()); + ->addValidator('contains', new ContainsValidatorFactory()) + ->addValidator('unevaluatedItems', new UnevaluatedItemsValidatorFactory()); $builder->getType('object') ->addValidator('unevaluatedProperties', new UnevaluatedPropertiesValidatorFactory()); diff --git a/src/Model/Validator/AbstractUnevaluatedItemsValidator.php b/src/Model/Validator/AbstractUnevaluatedItemsValidator.php new file mode 100644 index 00000000..38109db8 --- /dev/null +++ b/src/Model/Validator/AbstractUnevaluatedItemsValidator.php @@ -0,0 +1,48 @@ +`-form (`UnevaluatedItemsValidator`). + * + * Both emit a template that calls `$this->collectUnevaluatedIndices(...)` from + * `CompositionEvaluationTrait` with the array property's name and the composition-validator + * key list. The composition-validator key list is rendered as the empty literal `[]` until + * array-side composition branches start writing `'kind' => 'array'` slots into + * `_compositionEvaluations`; the trait method filters slots by `'kind'` so the swap to the + * real key list is purely additive. + */ +abstract class AbstractUnevaluatedItemsValidator extends PropertyTemplateValidator +{ + /** + * @param array $extraTemplateValues Subclass-specific template values merged + * on top of the shared set. + */ + public function __construct( + PropertyInterface $property, + string $templatePath, + string $exceptionClass, + array $exceptionParams, + array $extraTemplateValues = [], + ) { + parent::__construct( + $property, + $templatePath, + $extraTemplateValues + [ + 'arrayPropertyName' => $property->getName(), + // Empty literal until array-side composition branches write + // 'kind' => 'array' slots into _compositionEvaluations; the + // collectUnevaluatedIndices() filter ignores anything else, so the + // forward-compatible swap to a real key list is additive. + 'compositionValidatorKeys' => '[]', + ], + $exceptionClass, + $exceptionParams, + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php new file mode 100644 index 00000000..61ac20d9 --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php @@ -0,0 +1,157 @@ +getJson(); + + // `unevaluatedItems: true` is the spec default — every index is allowed. Absent keyword + // is treated the same way. + if (!array_key_exists($this->key, $json) || $json[$this->key] === true) { + return; + } + + $unevaluatedItems = $json[$this->key]; + + if (!is_bool($unevaluatedItems) && !is_array($unevaluatedItems)) { + throw new SchemaException( + sprintf( + "Invalid unevaluatedItems %s for property '%s' in file %s", + str_replace("\n", '', var_export($unevaluatedItems, true)), + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + if ($this->isDeadCode($schemaProcessor, $schema, $property, $json)) { + return; + } + + if ($unevaluatedItems === false) { + $property->addValidator( + new NoUnevaluatedItemsValidator($property), + self::VALIDATOR_PRIORITY, + ); + + return; + } + + $property->addValidator( + new UnevaluatedItemsValidator($schemaProcessor, $schema, $property, $propertySchema), + self::VALIDATOR_PRIORITY, + ); + } + + /** + * Three sibling-shape combinations make the unevaluatedItems keyword unreachable: the + * array is forced empty by `items: false`, every index is already claimed by an + * `items: {schema}`, or the tuple length is fully covered with `additionalItems: false`. + * Each one is spec-legal and emits a generation-time warning instead of a SchemaException + * — the developer's intent is intact but the keyword cannot contribute. + */ + private function isDeadCode( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + array $json, + ): bool { + if (!array_key_exists('items', $json)) { + return false; + } + + $items = $json['items']; + + if ($items === false) { + $this->warn( + $schemaProcessor, + $schema, + $property, + "sibling items: false rejects every index, leaving no unevaluated items", + ); + + return true; + } + + $isTupleItems = is_array($items) + && $items !== [] + && array_keys($items) === range(0, count($items) - 1); + + if ($isTupleItems) { + if (($json['additionalItems'] ?? null) === false) { + $this->warn( + $schemaProcessor, + $schema, + $property, + "sibling additionalItems: false rejects every tail index past the tuple", + ); + + return true; + } + + return false; + } + + // Schema-form items claims every index per the spec; nothing is left over for the + // unevaluatedItems keyword to validate. + if (is_array($items)) { + $this->warn( + $schemaProcessor, + $schema, + $property, + "sibling items: {schema} already validates every index", + ); + + return true; + } + + return false; + } + + private function warn( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + string $reason, + ): void { + if (!$schemaProcessor->getGeneratorConfiguration()->isOutputEnabled()) { + return; + } + + echo sprintf( + "Warning: unevaluatedItems on %s::%s is dead code — %s\n", + $schema->getClassName(), + $property->getName(), + $reason, + ); + } +} diff --git a/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php index 8262382e..b0a79459 100644 --- a/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php @@ -32,6 +32,19 @@ public function modify( return; } + $unevaluatedProperties = $json[$this->key]; + + if (!is_bool($unevaluatedProperties) && !is_array($unevaluatedProperties)) { + throw new SchemaException( + sprintf( + "Invalid unevaluatedProperties %s for property '%s' in file %s", + str_replace("\n", '', var_export($unevaluatedProperties, true)), + $schema->getClassName(), + $propertySchema->getFile(), + ), + ); + } + // When the same schema declares a non-false `additionalProperties`, every key not // claimed by `properties`/`patternProperties` is already evaluated by it. Composition // contributions cannot retract that — `additionalProperties` only succeeds when every @@ -50,7 +63,7 @@ public function modify( return; } - if ($json[$this->key] === false) { + if ($unevaluatedProperties === false) { $schema->addPostCompositionValidator( new NoUnevaluatedPropertiesValidator($schema, $propertySchema), ); diff --git a/src/Model/Validator/NoUnevaluatedItemsValidator.php b/src/Model/Validator/NoUnevaluatedItemsValidator.php new file mode 100644 index 00000000..6a36bf98 --- /dev/null +++ b/src/Model/Validator/NoUnevaluatedItemsValidator.php @@ -0,0 +1,30 @@ +isResolved = true; + + parent::__construct( + $property, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'NoUnevaluatedItems.phptpl', + UnevaluatedItemsException::class, + ['&$unevaluatedItems'], + ); + } +} diff --git a/src/Model/Validator/UnevaluatedItemsValidator.php b/src/Model/Validator/UnevaluatedItemsValidator.php new file mode 100644 index 00000000..a65977ac --- /dev/null +++ b/src/Model/Validator/UnevaluatedItemsValidator.php @@ -0,0 +1,87 @@ +`. + * + * Mirrors UnevaluatedPropertiesValidator on the array side: each index of the array not + * claimed by a sibling positive applicator must validate against the unevaluatedItems + * subschema. + */ +class UnevaluatedItemsValidator extends AbstractUnevaluatedItemsValidator +{ + private const NESTED_PROPERTY_NAME = 'unevaluated item'; + + private readonly PropertyInterface $validationProperty; + + /** + * @throws SchemaException + */ + public function __construct( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertiesStructure, + ) { + $this->validationProperty = (new PropertyFactory())->create( + $schemaProcessor, + $schema, + self::NESTED_PROPERTY_NAME, + $propertiesStructure->navigate('unevaluatedItems'), + true, + ); + + $this->validationProperty->onResolve(function (): void { + $this->resolve(); + }); + + parent::__construct( + $property, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'UnevaluatedItems.phptpl', + InvalidUnevaluatedItemsException::class, + ['&$invalidItems'], + [ + 'schema' => $schema, + 'validationProperty' => $this->validationProperty, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + ], + ); + } + + /** + * @inheritDoc + */ + public function getCheck(): string + { + $this->removeRequiredPropertyValidator($this->validationProperty); + + return parent::getCheck(); + } + + public function getValidationProperty(): PropertyInterface + { + return $this->validationProperty; + } + + /** + * Initialize the per-call error map captured by reference into the IIFE so the + * generated exception receives the populated list. + */ + public function getValidatorSetUp(): string + { + return '$invalidItems = [];'; + } +} diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php index eee50829..f2a9f977 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -242,7 +242,20 @@ private function needsActivation(Schema $schema, array &$seen): bool } // Check property-level nested schemas (e.g. an object-typed property with its own composition). + // Array properties never carry a nested schema — their unevaluatedItems lives in the + // property's own JSON, so check that before the getNestedSchema() recursion. Without + // this, a schema like {type: object, properties: {tags: {type: array, unevaluatedItems: false}}} + // would miss activation entirely. foreach ($schema->getProperties() as $schemaProperty) { + $propertyJson = $schemaProperty->getJsonSchema()->getJson(); + + if ( + array_key_exists('unevaluatedProperties', $propertyJson) + || array_key_exists('unevaluatedItems', $propertyJson) + ) { + return $seen[$schemaKey] = true; + } + $nestedSchema = $schemaProperty->getNestedSchema(); if ($nestedSchema !== null && $this->needsActivation($nestedSchema, $seen)) { diff --git a/src/Templates/Validator/NoUnevaluatedItems.phptpl b/src/Templates/Validator/NoUnevaluatedItems.phptpl new file mode 100644 index 00000000..7a5c6c84 --- /dev/null +++ b/src/Templates/Validator/NoUnevaluatedItems.phptpl @@ -0,0 +1,5 @@ +is_array($value) && ($unevaluatedItems = $this->collectUnevaluatedIndices( + $value, + '{{ arrayPropertyName }}', + {{ compositionValidatorKeys }}, +)) diff --git a/src/Templates/Validator/UnevaluatedItems.phptpl b/src/Templates/Validator/UnevaluatedItems.phptpl new file mode 100644 index 00000000..d4f6efc2 --- /dev/null +++ b/src/Templates/Validator/UnevaluatedItems.phptpl @@ -0,0 +1,44 @@ +is_array($value) && (function ($items) use (&$invalidItems, $modelData): bool { + {% if generatorConfiguration.collectErrors() %} + $originalErrorRegistry = $this->_errorRegistry; + {% endif %} + + $unevaluatedIndices = $this->collectUnevaluatedIndices( + $items, + '{{ arrayPropertyName }}', + {{ compositionValidatorKeys }}, + ); + + foreach ($unevaluatedIndices as $index) { + try { + $value = $items[$index]; + + {% if generatorConfiguration.collectErrors() %} + $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% endif %} + + {{ viewHelper.resolvePropertyDecorator(validationProperty) }} + + {% foreach validationProperty.getOrderedValidators() as validator %} + {{ viewHelper.renderValidator(validator, schema) }} + {% endforeach %} + + {% if generatorConfiguration.collectErrors() %} + if ($this->_errorRegistry->getErrors()) { + $invalidItems[$index] = $this->_errorRegistry->getErrors(); + continue; + } + {% endif %} + } catch (\Exception $e) { + isset($invalidItems[$index]) + ? $invalidItems[$index][] = $e + : $invalidItems[$index] = [$e]; + } + } + + {% if generatorConfiguration.collectErrors() %} + $this->_errorRegistry = $originalErrorRegistry; + {% endif %} + + return !empty($invalidItems); +})($value) diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php new file mode 100644 index 00000000..5b63a991 --- /dev/null +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -0,0 +1,281 @@ +rawInput()` unchanged. + * + * @return array}> + */ + public static function acceptanceProvider(): array + { + return [ + 'unevaluatedItems: false with empty array' => [ + 'NoOtherConstraintsFalse.json', + ['tags' => []], + ], + 'unevaluatedItems: false with array property absent' => [ + 'NoOtherConstraintsFalse.json', + [], + ], + 'unevaluatedItems schema-form accepts matching values' => [ + 'NoOtherConstraintsSchema.json', + ['tags' => ['alpha', 'beta', 'gamma']], + ], + 'unevaluatedItems schema-form accepts empty array' => [ + 'NoOtherConstraintsSchema.json', + ['tags' => []], + ], + ]; + } + + /** + * @param array $input + */ + #[DataProvider('acceptanceProvider')] + public function testAcceptedInputRoundTrips(string $schemaFile, array $input): void + { + $className = $this->generateClassFromFile($schemaFile); + $instance = new $className($input); + + $this->assertSame($input, $instance->meta()->rawInput()); + } + + /** + * Rejected input for the `false` form — construction must throw `UnevaluatedItemsException` + * with the expected message. Indices are reported with the `#` prefix that the rest of the + * array-side exception family uses (InvalidItemException, InvalidTupleException, etc.) — + * the pin includes the prefix so a regression that drops it surfaces here. + * + * @return array, 1: string}> + */ + public static function falseFormRejectionProvider(): array + { + return [ + // Single-element array — minimal case for the bracket+prefix render. + 'rejects single item' => [ + ['tags' => ['only']], + 'Provided JSON for tags contains not allowed unevaluated items [#0]', + ], + // Three-element array — exercises the comma-separated list path so a future change + // to the joiner shows up here. + 'reports every offending index' => [ + ['tags' => ['a', 'b', 'c']], + 'Provided JSON for tags contains not allowed unevaluated items [#0, #1, #2]', + ], + ]; + } + + /** + * @param array $input + */ + #[DataProvider('falseFormRejectionProvider')] + public function testFalseFormRejectsUnevaluatedIndicesWithFullMessage( + array $input, + string $expectedMessage, + ): void { + $className = $this->generateClassFromFile('NoOtherConstraintsFalse.json'); + + try { + new $className($input); + $this->fail('Expected UnevaluatedItemsException'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame($expectedMessage, $exception->getMessage()); + $this->assertSame( + array_keys($input['tags']), + $exception->getUnevaluatedItems(), + 'getUnevaluatedItems() must report the same indices the message lists', + ); + } + } + + /** + * Schema-form rejection wraps a per-index aggregate around the inner validation errors. + * The fixture array deliberately has two failing indices (integer at #1, boolean at #3) + * so the test exercises: + * + * - Aggregation across multiple failing indices in declaration order — a regression that + * dropped any one failure, reordered them, or collapsed them into a single line would + * fail the heredoc compare. + * - The nested exception list returned by `getInvalidItems()` keys each failure under + * its original index and preserves the per-failure `InvalidTypeException` unchanged, + * so a consumer that catches the wrapper can still surface the precise reason + * (type was integer / boolean, expected string) per index. + * + * The expected surface message is built via heredoc so the test source reads line-by-line + * exactly as the runtime message will. + */ + public function testSchemaFormRejectionPreservesNestedInvalidTypeExceptionForEachFailingIndex(): void + { + $className = $this->generateClassFromFile('NoOtherConstraintsSchema.json'); + + try { + new $className(['tags' => ['ok', 42, 'also-ok', true]]); + $this->fail('Expected InvalidUnevaluatedItemsException'); + } catch (InvalidUnevaluatedItemsException $exception) { + $this->assertSame( + <<<'MSG' + Invalid unevaluated items in array tags: + - invalid unevaluated item #1 + * Invalid type for unevaluated item. Requires string, got integer + - invalid unevaluated item #3 + * Invalid type for unevaluated item. Requires string, got boolean + MSG, + $exception->getMessage(), + ); + + $invalidItems = $exception->getInvalidItems(); + $this->assertSame( + [1, 3], + array_keys($invalidItems), + 'only the two non-string indices should fail, keyed by their original index', + ); + + $this->assertCount(1, $invalidItems[1], 'one inner exception per failing index'); + $this->assertCount(1, $invalidItems[3], 'one inner exception per failing index'); + + $integerFailure = $invalidItems[1][0]; + $this->assertInstanceOf(InvalidTypeException::class, $integerFailure); + $this->assertSame( + 'Invalid type for unevaluated item. Requires string, got integer', + $integerFailure->getMessage(), + ); + $this->assertSame('string', $integerFailure->getExpectedType()); + + $booleanFailure = $invalidItems[3][0]; + $this->assertInstanceOf(InvalidTypeException::class, $booleanFailure); + $this->assertSame( + 'Invalid type for unevaluated item. Requires string, got boolean', + $booleanFailure->getMessage(), + ); + $this->assertSame('string', $booleanFailure->getExpectedType()); + } + } + + /** + * `uniqueItems: true` is orthogonal to `unevaluatedItems`. uniqueItems failure has its own + * exception identity and message; the unevaluated check does not get to fire for that + * array because the uniqueItems failure surfaces first. + */ + public function testUniqueItemsExceptionIdentityIsPreserved(): void + { + $this->expectException(UniqueItemsException::class); + + $className = $this->generateClassFromFile('UnevaluatedFalseWithUniqueItems.json'); + new $className(['tags' => ['dup', 'dup']]); + } + + /** + * Three sibling shapes make `unevaluatedItems` unreachable; each emits a generation-time + * warning instead of a SchemaException. The warning pin captures the developer's intended + * class/property identifier so the source of the dead code is obvious in build output. + * + * @return array + */ + public static function deadCodeProvider(): array + { + return [ + // items: {schema} claims every index per the JSON Schema spec; the unevaluated + // bucket is permanently empty, so the false form can never fire. + 'items schema form (false)' => [ + 'ItemsSchemaWithUnevaluatedFalse.json', + "sibling items: {schema} already validates every index", + ], + // Same dead-cell shape as above but with the schema form of unevaluatedItems — + // the keyword still has nothing left to validate. + 'items schema form (schema)' => [ + 'ItemsSchemaWithUnevaluatedSchema.json', + "sibling items: {schema} already validates every index", + ], + // items: false reduces the array to empty; no index can be unevaluated. + 'items: false leaves no indices' => [ + 'ItemsFalseWithUnevaluatedFalse.json', + "sibling items: false rejects every index, leaving no unevaluated items", + ], + // Tuple items + additionalItems: false rejects everything past the tuple length; + // the unevaluatedItems keyword cannot contribute. + 'tuple items with additionalItems: false' => [ + 'TupleAdditionalFalseWithUnevaluatedFalse.json', + "sibling additionalItems: false rejects every tail index past the tuple", + ], + ]; + } + + #[DataProvider('deadCodeProvider')] + public function testDeadCodeShapesWarn(string $schemaFile, string $reasonFragment): void + { + $this->expectOutputRegex( + '/Warning: unevaluatedItems on \S+::tags is dead code — ' . preg_quote($reasonFragment, '/') . '/', + ); + + $className = $this->generateClassFromFile( + $schemaFile, + (new GeneratorConfiguration())->setOutputEnabled(true), + ); + + // Each warned shape still compiles into a working class — an empty tags array + // constructs cleanly, proving the keyword did not break codegen. + $instance = new $className(['tags' => []]); + $this->assertSame(['tags' => []], $instance->meta()->rawInput()); + } + + /** + * Non-bool / non-object values for `unevaluatedProperties` and `unevaluatedItems` must + * fail loudly at generation time with a SchemaException. The generator never produces + * broken code for these inputs; CLAUDE.md's "Schema error handling" rule applies. + * + * @return array + */ + public static function invalidTypeProvider(): array + { + return [ + 'unevaluatedItems with integer value' => [ + 'InvalidUnevaluatedItemsType.json', + 'unevaluatedItems', + ], + 'unevaluatedProperties with integer value' => [ + 'InvalidUnevaluatedPropertiesType.json', + 'unevaluatedProperties', + ], + ]; + } + + #[DataProvider('invalidTypeProvider')] + public function testInvalidTypeForKeywordThrowsSchemaException( + string $schemaFile, + string $keyword, + ): void { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + '/^Invalid ' . preg_quote($keyword, '/') . ' 42 for property \'\S+\' in file /', + ); + + $this->generateClassFromFile($schemaFile); + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsType.json b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsType.json new file mode 100644 index 00000000..99511a5b --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsType.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "unevaluatedItems": 42 + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesType.json b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesType.json new file mode 100644 index 00000000..82cbb4d5 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesType.json @@ -0,0 +1,4 @@ +{ + "type": "object", + "unevaluatedProperties": 42 +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ItemsFalseWithUnevaluatedFalse.json b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsFalseWithUnevaluatedFalse.json new file mode 100644 index 00000000..8a6cd69c --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsFalseWithUnevaluatedFalse.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": false, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedFalse.json b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedFalse.json new file mode 100644 index 00000000..3d83d142 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedFalse.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "integer" + }, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedSchema.json b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedSchema.json new file mode 100644 index 00000000..035eb483 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedSchema.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "integer" + }, + "unevaluatedItems": { + "type": "string" + } + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsFalse.json b/tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsFalse.json new file mode 100644 index 00000000..59b3f72e --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsFalse.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsSchema.json b/tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsSchema.json new file mode 100644 index 00000000..b8750177 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsSchema.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "unevaluatedItems": { + "type": "string" + } + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalFalseWithUnevaluatedFalse.json b/tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalFalseWithUnevaluatedFalse.json new file mode 100644 index 00000000..224ce728 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalFalseWithUnevaluatedFalse.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": [ + { + "type": "integer" + } + ], + "additionalItems": false, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedFalseWithUniqueItems.json b/tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedFalseWithUniqueItems.json new file mode 100644 index 00000000..76d79760 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedFalseWithUniqueItems.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "uniqueItems": true, + "unevaluatedItems": false + } + } +} From cf1d5ea066ce5994610a4eb3270251da5855366f Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 29 Jun 2026 00:53:20 +0200 Subject: [PATCH 11/51] Add array-side composition annotation propagation for unevaluatedItems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Property-level allOf/anyOf/oneOf/if-then-else compositions on array properties now contribute their successful branches' claimed indices to a sibling unevaluatedItems validator. The bridge is a single transient _compositionAnnotated field indexed by slot key, written wholesale at end of each composition IIFE, read by the unevaluatedItems template via the prod-lib trait method. The activation walk in UnevaluatedPropertiesPostProcessor descends recursively through composition branches' wrapped properties (so nested compositions get tracking too) and flags any ArrayContains validators inside tracked branches with trackBranchMatches, which makes the contains template export a per-index match map captured by the surrounding composition body. Sharp edges fixed along the way: * ExtractedMethodValidator method-name hash mixed in spl_object_hash so two compositions on the same property no longer collide into one method body * Activation walk reads source validators via getWrappedProperty() because PropertyProxy::getOrderedValidators() returns fresh withProperty() clones on every call — flag mutations on the clones never reached the rendered instance * Composition IIFE leaves $value untouched when slotKey is set so a failing composition does not null the caller's array value and suppress the downstream unevaluatedItems check in collectErrors mode * activateArrayComposition has a hash-keyed cycle guard so a self- referencing schema does not recurse indefinitely through $ref-induced composition cycles Requires php-micro-template 1.11.0 for the {# ... #} template-time comment syntax used by ComposedItem.phptpl. CLAUDE.md adds two rules surfaced by the work: never prefix local variables with underscore (the prefix is reserved for class members); when a bug is found, write a reproducing test before fixing. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 72 ++++++ composer.json | 2 +- .../Property/CompositionPropertyDecorator.php | 82 +++++++ .../AbstractComposedPropertyValidator.php | 22 ++ .../AbstractUnevaluatedItemsValidator.php | 42 +++- .../Validator/ArrayContainsValidator.php | 62 +++++ .../Validator/ExtractedMethodValidator.php | 2 +- .../Arrays/ContainsValidatorFactory.php | 29 +-- .../UnevaluatedPropertiesPostProcessor.php | 112 +++++++++ src/Templates/Validator/ArrayContains.phptpl | 19 +- src/Templates/Validator/ComposedItem.phptpl | 85 +++++-- .../Validator/ConditionalComposedItem.phptpl | 217 +++++++++++------ .../Validator/NoUnevaluatedItems.phptpl | 2 +- .../Validator/UnevaluatedItems.phptpl | 2 +- tests/Basic/UnevaluatedItemsValidatorTest.php | 225 ++++++++++++++++++ .../AllOfTupleBranches.json | 28 +++ ...yAndObjectPropertyCompositionsCoexist.json | 31 +++ .../ContainsOnlyBranch.json | 16 ++ .../ItemsPlusContainsBranch.json | 21 ++ .../MultipleCompositionsOnProperty.json | 30 +++ .../SelfReferencingArrayComposition.json | 19 ++ 21 files changed, 990 insertions(+), 130 deletions(-) create mode 100644 src/Model/Validator/ArrayContainsValidator.php create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/AllOfTupleBranches.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/ArrayAndObjectPropertyCompositionsCoexist.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/ContainsOnlyBranch.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/ItemsPlusContainsBranch.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/MultipleCompositionsOnProperty.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/SelfReferencingArrayComposition.json diff --git a/CLAUDE.md b/CLAUDE.md index df1bb57e..e7b4d4c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -271,6 +271,15 @@ Never use single-character variable names. All variables must have meaningful, d that convey their purpose. For example, use `$typeName` instead of `$t`, `$validator` instead of `$v`, `$property` instead of `$p`. +Never prefix local variables with an underscore. The underscore prefix is reserved for *class +member* identifiers (instance properties, internal methods like `_validateTags`) where it marks +the symbol as internal-by-convention. Applying the same prefix to local variables blurs the +member/local distinction without adding information. Use the plain name instead: +`$branchContainsMatches`, not `$_branchContainsMatches`. The rule applies symmetrically to +variables declared inside generated template code that escape into the generated PHP output — +local variables inside template-emitted closures and IIFEs are still local PHP variables and +must not carry the underscore prefix. + ### PHP import style Always add `use` imports for every class referenced in a file, including global PHP classes such as @@ -384,6 +393,69 @@ priority ordering, etc.), that is precisely the issue that needs solving. Open i topic if it cannot be addressed immediately, but keep the test in place and marked as expected to fail (`@expectedExceptionMessage`, `$this->expectException(...)`) until the fix lands. +#### How to handle every bug found during development + +This rule generalises the test-evasion rule above to every bug, however it is discovered — +through a failing test, while reading code, during a debugging session, or as a side-observation +in an unrelated task. + +**Every bug must be acknowledged explicitly the moment it is found.** State, in the user-facing +response, *exactly* what the bug is — the failure mode, the code path, and a minimal reproducer +or pointer to one. Do not let bugs surface implicitly as test failures the user has to dig out +of the log; surface them in prose. + +**Write a test that reproduces the bug *before* fixing it.** This applies whether the bug is +about to be fixed in the same change (path 1 below) or deferred (path 2). The test name encodes +the specific scenario so a regression surfaces immediately as a named, self-explaining failure +rather than a cryptic assertion error elsewhere. When deferring, the test is marked failing +(`$this->expectException(...)` plus an explicit assertion of the *current wrong* behaviour, or +`#[Test] #[ExpectedFailure]` if the framework supports it) so the gap is visible in CI until +the fix lands. When fixing in the same change, the test starts red and turns green as the fix +lands — the diff carries proof that the change actually closes the reported scenario, not just +that other tests still pass. Sequence: reproduce → confirm red → fix → confirm green. Do not +skip the "confirm red" step; a test that was always green is no evidence of anything. + +**Every acknowledged bug takes one of three paths. Choose explicitly, never silently:** + +1. **Fix it in the same change.** Default path. If the bug is reachable from the work in + progress and fixing it does not balloon the diff into an unrelated refactor, fix it now and + note the fix in the response. +2. **Defer with a tracked artifact.** Only when the fix is genuinely out of scope (different + subsystem, requires user direction on architecture, blocked on external work). Deferral + requires *both*: + - A tracking entry: a `.claude/topics//` plan stub, a `@expectedException` test + fixture marked failing, OR an entry in the active plan's post-implementation review list + — whichever fits the current workflow. + - A surfacing mechanism in the codebase: the failing test, a `throw new \LogicException(...)` + at the unreachable site, or a documented assertion. Comments alone are not enough — a + comment without an enforcement mechanism rots silently. + + State the deferral and the chosen tracking + surfacing mechanism in the same response that + announces the bug. +3. **Reject it as not-a-bug.** When closer reading reveals the apparent bug is correct behaviour + under a constraint you missed. Explain the constraint in the response and update any + misleading comment, test, or doc that suggested otherwise. + +**Routing around a bug is forbidden.** Removing a test, swapping a fixture for one that does not +trigger the bug, narrowing a test's assertion to skip the affected output, choosing an +alternative implementation path purely to avoid touching the buggy subsystem, or deciding "this +edge case is rare so I'll not test it" — all of these are silent suppression. They are explicitly +not in the deferral path: deferral requires the bug to remain *visible*, just not yet fixed. + +**A bug downstream of your change is still your bug.** When your edit causes a previously-passing +test to fail, the test is reporting a real defect in your change — even if the test was +"unrelated" before. Do not dismiss it as "pre-existing brittleness"; the failure path is now in +scope. Either your change has a bug, or the test was wrong all along and now is the right time +to fix it (explicitly, with justification). The same applies symmetrically: when you discover an +existing bug while reading code, the "broken windows" rule from "Pre-existing rule violations in +touched files" still applies — flag it, then decide between paths 1, 2, and 3. + +**Why this matters.** Silent bug suppression is the most insidious form of code rot because each +individual instance looks like a reasonable scope-management decision. Over a long session the +cumulative effect is a codebase where everyone knows "you can't go that way" and the deferred +defects compound. Surfacing every bug — every time, in prose, with a path forward — is the only +discipline that prevents this. + #### No implementation-plan references in code Do not embed references to implementation-plan phases, section numbers, decision identifiers, diff --git a/composer.json b/composer.json index ce993ea0..6a02db3e 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ ], "require": { "wol-soft/php-json-schema-model-generator-production": "dev-jsonSchemaDraft2019", - "wol-soft/php-micro-template": "^1.10.2", + "wol-soft/php-micro-template": "^1.11.0", "php": ">=8.4", "ext-mbstring": "*" diff --git a/src/Model/Property/CompositionPropertyDecorator.php b/src/Model/Property/CompositionPropertyDecorator.php index 43f3b213..640ff67e 100644 --- a/src/Model/Property/CompositionPropertyDecorator.php +++ b/src/Model/Property/CompositionPropertyDecorator.php @@ -83,6 +83,18 @@ public function getBranchSchema(): JsonSchema return $this->jsonSchema; } + /** + * Return the wrapped property whose validators back this branch. Iterating its + * validators directly returns the source validator instances, whereas + * `getOrderedValidators()` on the decorator returns fresh `withProperty(...)` clones on + * every call. Mutations targeted at clones are invisible at render time; mutations + * targeted at the wrapped property's source validators propagate. + */ + public function getWrappedProperty(): PropertyInterface + { + return $this->definitionsCollection->offsetGet(self::PROPERTY_KEY); + } + /** * Returns the property names declared in this branch's `properties` keyword. * @@ -133,4 +145,74 @@ public function branchHasNonFalseAdditionalProperties(): bool return isset($branchJson['additionalProperties']) && $branchJson['additionalProperties'] !== false; } + + /** + * True when the branch declares `items` as a schema object (not a tuple list, not a + * boolean). A schema-form `items` claims every index in the validated array. + */ + public function branchHasItemsSchema(): bool + { + $items = $this->jsonSchema->getJson()['items'] ?? null; + + return is_array($items) && $items !== [] && !array_is_list($items); + } + + /** + * Count of items in a tuple-form `items` array. Returns 0 when items is absent, boolean, + * or schema-form. The tuple claims indices 0..count-1 when the branch succeeds. + */ + public function getBranchTupleItemsCount(): int + { + $items = $this->jsonSchema->getJson()['items'] ?? null; + + if (!is_array($items) || $items === [] || !array_is_list($items)) { + return 0; + } + + return count($items); + } + + /** + * True when the branch declares `additionalItems` as anything other than `false`. The + * `false` value rejects tail indices; any other value (true or schema object) accepts + * them and contributes them to the evaluated set. + */ + public function branchHasNonFalseAdditionalItems(): bool + { + $branchJson = $this->jsonSchema->getJson(); + + return array_key_exists('additionalItems', $branchJson) + && $branchJson['additionalItems'] !== false; + } + + /** + * True when the branch declares the `contains` keyword. The actual matched indices are + * collected at runtime by the contains validator into a local accumulator; the composition + * template unions that accumulator into the branch's evaluated set on success. + */ + public function branchHasContains(): bool + { + return array_key_exists('contains', $this->jsonSchema->getJson()); + } + + /** + * True when the branch carries at least one array-side applicator (`items`, + * `additionalItems`, `contains`) and no object-side applicators (`properties`, + * `additionalProperties`, `patternProperties`). Drives whether the composition template + * writes the branch's slot into `_compositionAnnotated`. + */ + public function branchIsArrayKind(): bool + { + $branchJson = $this->jsonSchema->getJson(); + + $hasArrayApplicator = array_key_exists('items', $branchJson) + || array_key_exists('additionalItems', $branchJson) + || array_key_exists('contains', $branchJson); + + $hasObjectApplicator = array_key_exists('properties', $branchJson) + || array_key_exists('additionalProperties', $branchJson) + || array_key_exists('patternProperties', $branchJson); + + return $hasArrayApplicator && !$hasObjectApplicator; + } } diff --git a/src/Model/Validator/AbstractComposedPropertyValidator.php b/src/Model/Validator/AbstractComposedPropertyValidator.php index 0d47e256..7ec8e9cc 100644 --- a/src/Model/Validator/AbstractComposedPropertyValidator.php +++ b/src/Model/Validator/AbstractComposedPropertyValidator.php @@ -23,6 +23,8 @@ abstract class AbstractComposedPropertyValidator extends ExtractedMethodValidato private bool $evaluationTrackingEnabled = false; + private ?string $slotKey = null; + public function getCompositionProcessor(): string { return $this->compositionProcessor; @@ -50,6 +52,26 @@ public function hasEvaluationTrackingEnabled(): bool return $this->evaluationTrackingEnabled; } + /** + * Identifier under which this validator's per-call result (the union of indices claimed + * by successful array-side branches) is cached on the model instance. When set, the + * composition template writes the result wholesale to `$this->_compositionAnnotated[$slotKey]` + * at end-of-IIFE: `[]` on whole-composition failure, the union otherwise. Null when the + * validator is not part of an array-side tracking chain — the template then takes its + * pre-existing object-side path against `$this->_compositionEvaluations[$validatorIndex]` + * instead. + */ + public function setSlotKey(string $slotKey): void + { + $this->slotKey = $slotKey; + $this->templateValues['slotKey'] = $slotKey; + } + + public function getSlotKey(): ?string + { + return $this->slotKey; + } + /** * Returns true when this validator implements `not` composition semantics. * diff --git a/src/Model/Validator/AbstractUnevaluatedItemsValidator.php b/src/Model/Validator/AbstractUnevaluatedItemsValidator.php index 38109db8..6fc5cfd3 100644 --- a/src/Model/Validator/AbstractUnevaluatedItemsValidator.php +++ b/src/Model/Validator/AbstractUnevaluatedItemsValidator.php @@ -5,20 +5,23 @@ namespace PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Property\PropertyInterface; +use PHPModelGenerator\Utils\RenderHelper; /** * Shared scaffolding for the two unevaluatedItems validator flavours: the `false`-form * (`NoUnevaluatedItemsValidator`) and the ``-form (`UnevaluatedItemsValidator`). * * Both emit a template that calls `$this->collectUnevaluatedIndices(...)` from - * `CompositionEvaluationTrait` with the array property's name and the composition-validator - * key list. The composition-validator key list is rendered as the empty literal `[]` until - * array-side composition branches start writing `'kind' => 'array'` slots into - * `_compositionEvaluations`; the trait method filters slots by `'kind'` so the swap to the - * real key list is purely additive. + * `CompositionEvaluationTrait` with the array property's name and a list of slot keys + * identifying the sibling composition validators whose annotations should contribute. + * The slot-key list is resolved at `getCheck()` time because the owning property's + * composition validators receive their slot keys during the post-processor pass that runs + * after the validator is constructed. */ abstract class AbstractUnevaluatedItemsValidator extends PropertyTemplateValidator { + private readonly PropertyInterface $parentProperty; + /** * @param array $extraTemplateValues Subclass-specific template values merged * on top of the shared set. @@ -30,19 +33,38 @@ public function __construct( array $exceptionParams, array $extraTemplateValues = [], ) { + $this->parentProperty = $property; + parent::__construct( $property, $templatePath, $extraTemplateValues + [ 'arrayPropertyName' => $property->getName(), - // Empty literal until array-side composition branches write - // 'kind' => 'array' slots into _compositionEvaluations; the - // collectUnevaluatedIndices() filter ignores anything else, so the - // forward-compatible swap to a real key list is additive. - 'compositionValidatorKeys' => '[]', + 'compositionSlotKeys' => '[]', ], $exceptionClass, $exceptionParams, ); } + + public function getCheck(): string + { + $slotKeys = []; + + foreach ($this->parentProperty->getOrderedValidators() as $validator) { + if (!$validator instanceof AbstractComposedPropertyValidator) { + continue; + } + + $slotKey = $validator->getSlotKey(); + + if ($slotKey !== null) { + $slotKeys[] = $slotKey; + } + } + + $this->templateValues['compositionSlotKeys'] = RenderHelper::varExportArray($slotKeys); + + return parent::getCheck(); + } } diff --git a/src/Model/Validator/ArrayContainsValidator.php b/src/Model/Validator/ArrayContainsValidator.php new file mode 100644 index 00000000..17f46c61 --- /dev/null +++ b/src/Model/Validator/ArrayContainsValidator.php @@ -0,0 +1,62 @@ + $nestedProperty, + 'schema' => $schema, + 'countMatches' => $countMatches, + 'allowNoMatch' => $allowNoMatch, + 'trackBranchMatches' => false, + 'viewHelper' => new RenderHelper($generatorConfiguration), + 'generatorConfiguration' => $generatorConfiguration, + ], + ContainsException::class, + ); + } + + /** + * Enable the per-index match map: the rendered IIFE captures `$_branchContainsMatches` + * by reference from its surrounding scope and writes `true` at each matched index. The + * composition template in a tracked branch sets up the accumulator and unions it into + * the branch's evaluated index set on success. + */ + public function setTrackBranchMatches(bool $trackBranchMatches): void + { + $this->templateValues['trackBranchMatches'] = $trackBranchMatches; + } + + public function getValidatorSetUp(): string + { + return $this->templateValues['countMatches'] ? ' + $containsMatches = 0; + ' : ''; + } +} diff --git a/src/Model/Validator/ExtractedMethodValidator.php b/src/Model/Validator/ExtractedMethodValidator.php index cd000fd8..ff8dfa91 100644 --- a/src/Model/Validator/ExtractedMethodValidator.php +++ b/src/Model/Validator/ExtractedMethodValidator.php @@ -29,7 +29,7 @@ public function __construct( '_validate%s_%s_%s', str_replace(' ', '', ucfirst($property->getAttribute())), str_replace('Validator', '', substr(strrchr(static::class, '\\'), 1)), - md5(json_encode($property->getJsonSchema()->getJson())), + md5(json_encode($property->getJsonSchema()->getJson()) . spl_object_hash($this)), ); parent::__construct($property, $template, $templateValues, $exceptionClass, $exceptionParams); diff --git a/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php index c2d546c3..ff086e04 100644 --- a/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php +++ b/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php @@ -11,12 +11,11 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; +use PHPModelGenerator\Model\Validator\ArrayContainsValidator; use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory; -use PHPModelGenerator\Model\Validator\PropertyTemplateValidator; use PHPModelGenerator\Model\Validator\PropertyValidator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; -use PHPModelGenerator\Utils\RenderHelper; class ContainsValidatorFactory extends AbstractValidatorFactory { @@ -74,26 +73,14 @@ public function modify( (array_key_exists('minContains', $json) || array_key_exists('maxContains', $json)); $property->addValidator( - new class ( + new ArrayContainsValidator( $property, - DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ArrayContains.phptpl', - [ - 'property' => $nestedProperty, - 'schema' => $schema, - 'countMatches' => $countMatches, - 'allowNoMatch' => ($json['minContains'] ?? 1) === 0, - 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), - 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), - ], - ContainsException::class, - ) extends PropertyTemplateValidator { - public function getValidatorSetUp(): string - { - return $this->templateValues['countMatches'] ? ' - $containsMatches = 0; - ' : ''; - } - }, + $nestedProperty, + $schema, + $schemaProcessor->getGeneratorConfiguration(), + $countMatches, + ($json['minContains'] ?? 1) === 0, + ), ); if (!$countMatches) { diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php index f2a9f977..bdd38741 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -6,12 +6,14 @@ use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Model\MethodInterface; +use PHPModelGenerator\Model\Property\CompositionPropertyDecorator; 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\AbstractComposedPropertyValidator; +use PHPModelGenerator\Model\Validator\ArrayContainsValidator; use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; use PHPModelGenerator\Traits\CompositionEvaluationTrait; @@ -41,6 +43,27 @@ */ class UnevaluatedPropertiesPostProcessor extends PostProcessor { + /** + * Monotonically increasing counter scoped to a single ($schema, $property) activation + * pass. Reset per property at the start of its walk so each property's compositions + * receive `_0`, `_1`, ... slot keys. Tracked on the + * instance so the recursive activation helpers do not need to thread a by-reference + * parameter through every call. + */ + private int $slotKeyCounter = 0; + + /** + * Object hashes of composition validators already activated in the current property + * walk. Required (not defensive) — a self-referencing schema such as + * `{type: array, allOf: [{$ref: "#"}], unevaluatedItems: false}` produces a composition + * validator whose composed property's wrapped property carries the same composition + * validator instance. Without this short-circuit, `activateArrayComposition()` would + * recurse indefinitely. + * + * @var array + */ + private array $activatedCompositions = []; + public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void { $seen = []; @@ -80,11 +103,100 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu ->setDefaultValue([]), ); + // Transient bridge between property-level array compositions and a sibling + // unevaluatedItems validator. Wholesale-overwritten per chain run; never registered + // with the rollback registry, never snapshotted across setter calls. + $schema->addProperty( + (new Property( + 'compositionAnnotated', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + )) + ->setInternal(true) + ->setDefaultValue([]), + ); + foreach ($schema->getBaseValidators() as $baseValidator) { if ($baseValidator instanceof AbstractComposedPropertyValidator) { $baseValidator->enableEvaluationTracking(); } } + + foreach ($schema->getProperties() as $schemaProperty) { + $propertyJson = $schemaProperty->getJsonSchema()->getJson(); + + if (!array_key_exists('unevaluatedItems', $propertyJson)) { + continue; + } + + // The unevaluatedItems factory is registered on the `array` type only, so a + // property whose type cannot hold an array never produces an unevaluatedItems + // validator at runtime. Activating compositions in that case would write + // `_compositionAnnotated` slots that nobody reads — wasted state. + $typeNames = $schemaProperty->getType()?->getNames() ?? []; + if ($typeNames !== [] && !in_array('array', $typeNames, true)) { + continue; + } + + $this->slotKeyCounter = 0; + $this->activatedCompositions = []; + + foreach ($schemaProperty->getOrderedValidators() as $validator) { + if ($validator instanceof AbstractComposedPropertyValidator) { + $this->activateArrayComposition($validator, $schemaProperty); + } + } + } + } + + /** + * Enable evaluation tracking on a property-level composition validator on an array + * property and recurse into its branches. The slot-key counter on the post-processor + * instance is shared across the recursion so an outer composition and a nested one + * inside one of its branches receive monotonically increasing keys (e.g. `tags_0` for + * the outer allOf, `tags_1` for an inner oneOf). Within each branch, any ArrayContains + * validator gets its trackBranchMatches flag set so the contains template exports per- + * index match results to the surrounding composition body. + * + * The instance-level `$activatedCompositions` set guards against `$ref`-induced cycles + * in the composition graph; activating the same validator twice would double-emit slot + * writes and confuse the rebuild. + */ + private function activateArrayComposition( + AbstractComposedPropertyValidator $compositionValidator, + PropertyInterface $parentProperty, + ): void { + $compositionHash = spl_object_hash($compositionValidator); + if (isset($this->activatedCompositions[$compositionHash])) { + return; + } + $this->activatedCompositions[$compositionHash] = true; + + $compositionValidator->enableEvaluationTracking(); + $compositionValidator->setSlotKey($parentProperty->getName() . '_' . $this->slotKeyCounter++); + + foreach ($compositionValidator->getComposedProperties() as $composedProperty) { + $this->activateValidatorsInBranch($composedProperty, $parentProperty); + } + } + + private function activateValidatorsInBranch( + CompositionPropertyDecorator $composedProperty, + PropertyInterface $parentProperty, + ): void { + // Iterate the wrapped property's validators directly. The decorator's + // getOrderedValidators() returns fresh withProperty() clones every call, so a + // mutation on those clones would be invisible at render time. + foreach ($composedProperty->getWrappedProperty()->getOrderedValidators() as $validator) { + if ($validator instanceof AbstractComposedPropertyValidator) { + $this->activateArrayComposition($validator, $parentProperty); + continue; + } + + if ($validator instanceof ArrayContainsValidator) { + $validator->setTrackBranchMatches(true); + } + } } /** diff --git a/src/Templates/Validator/ArrayContains.phptpl b/src/Templates/Validator/ArrayContains.phptpl index 64690df2..e083c6f3 100644 --- a/src/Templates/Validator/ArrayContains.phptpl +++ b/src/Templates/Validator/ArrayContains.phptpl @@ -1,4 +1,4 @@ -is_array($value) && (function (&$items){% if countMatches %} use (&$containsMatches){% endif %} { +is_array($value) && (function (&$items){% if countMatches or trackBranchMatches %} use ({% if countMatches %}&$containsMatches{% endif %}{% if countMatches and trackBranchMatches %}, {% endif %}{% if trackBranchMatches %}&$branchContainsMatches{% endif %}){% endif %} { if (empty($items)) { return {% if allowNoMatch %}false{% else %}true{% endif %}; } @@ -7,7 +7,7 @@ is_array($value) && (function (&$items){% if countMatches %} use (&$containsMatc $originalErrorRegistry = $this->_errorRegistry; {% endif %} - foreach ($items as &$value) { + foreach ($items as $index => &$value) { try { {% if generatorConfiguration.collectErrors() %} $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); @@ -27,10 +27,15 @@ is_array($value) && (function (&$items){% if countMatches %} use (&$containsMatc $this->_errorRegistry = $originalErrorRegistry; {% endif %} + {% if trackBranchMatches %} + $branchContainsMatches[$index] = true; + {% endif %} + {% if countMatches %} $containsMatches++; - {% else %} - // one matched item is enough to pass the contains check + {% endif %} + {% if not countMatches and not trackBranchMatches %} + // one matched item is enough; no need to keep iterating return false; {% endif %} } catch (\Exception $e) { @@ -45,6 +50,10 @@ is_array($value) && (function (&$items){% if countMatches %} use (&$containsMatc return {% if countMatches %} {% if allowNoMatch %}false{% else %}$containsMatches === 0{% endif %} {% else %} - true + {% if trackBranchMatches %} + empty($branchContainsMatches) + {% else %} + true + {% endif %} {% endif %}; })($value) \ No newline at end of file diff --git a/src/Templates/Validator/ComposedItem.phptpl b/src/Templates/Validator/ComposedItem.phptpl index f2a16dff..e1428fb5 100644 --- a/src/Templates/Validator/ComposedItem.phptpl +++ b/src/Templates/Validator/ComposedItem.phptpl @@ -14,11 +14,16 @@ $proposedValue = null; $modifiedValues = []; + {% if compositionValidator.getSlotKey() %} + $slotKey = '{{ compositionValidator.getSlotKey() }}'; + $compositionEvaluatedIndices = []; + {% endif %} + {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} $originalPropertyValidationState = $this->_propertyValidationState ?? []; {% endif %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} $originalCompositionEvaluations = $this->_compositionEvaluations ?? []; {% endif %} @@ -27,7 +32,7 @@ {% endif %} {% foreach compositionProperties as compositionProperty %} - {% if compositionValidator.isNotComposition() and compositionValidator.hasEvaluationTrackingEnabled() %} + {% if compositionValidator.isNotComposition() and compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} $preNotBranchEvaluations = $this->_compositionEvaluations ?? []; {% endif %} @@ -81,6 +86,10 @@ $proposedValue = $proposedValue ?? $value; {% endif %} + {% if compositionValidator.getSlotKey() and compositionProperty.branchHasContains() %} + $branchContainsMatches = []; + {% endif %} + {{ viewHelper.resolvePropertyDecorator(compositionProperty) }} {% foreach compositionProperty.getOrderedValidators() as validator %} @@ -122,40 +131,62 @@ } {% endif %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and compositionProperty.branchIsArrayKind() %} + if ($shouldTrack) { + {% if compositionProperty.branchHasItemsSchema() %} + $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); + {% endif %} + {% if not compositionProperty.branchHasItemsSchema() and compositionProperty.getBranchTupleItemsCount() %} + $compositionEvaluatedIndices += array_fill( + 0, + min({{ compositionProperty.getBranchTupleItemsCount() }}, count($originalModelData)), + true, + ); + {% endif %} + {% if not compositionProperty.branchHasItemsSchema() and compositionProperty.branchHasNonFalseAdditionalItems() %} + $compositionEvaluatedIndices += array_fill( + {{ compositionProperty.getBranchTupleItemsCount() }}, + max(0, count($originalModelData) - {{ compositionProperty.getBranchTupleItemsCount() }}), + true, + ); + {% endif %} + {% if compositionProperty.branchHasContains() %} + $compositionEvaluatedIndices += $branchContainsMatches; + {% endif %} + } + {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} if ($shouldTrack) { {% if compositionProperty.branchHasNonFalseAdditionalProperties() %} $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = true; - {% else %} - {% if compositionProperty.getNestedSchema() %} - $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = is_object($value) ? $value : null; - {% else %} - $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = $this->collectEvaluatedProperties( - $modelData, - {{ compositionProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, - {{ compositionProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, - ); - {% endif %} + {% endif %} + {% if not compositionProperty.branchHasNonFalseAdditionalProperties() and compositionProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = is_object($value) ? $value : null; + {% endif %} + {% if not compositionProperty.branchHasNonFalseAdditionalProperties() and not compositionProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = $this->collectEvaluatedProperties( + $modelData, + {{ compositionProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, + {{ compositionProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, + ); {% endif %} } {% endif %} } catch (\Exception $e) { - {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) - and not generatorConfiguration.collectErrors() - %} + {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) and not generatorConfiguration.collectErrors() %} if (isset($validatorIndex)) { $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex] = false; } {% endif %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = null; {% endif %} $succeededCompositionElements--; } - {% if compositionValidator.isNotComposition() and compositionValidator.hasEvaluationTrackingEnabled() %} + {% if compositionValidator.isNotComposition() and compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} // Restore composition evaluations so any annotations written by the not-branch body // (including by nested UnevaluatedPropertiesValidators) cannot leak to the parent schema. $this->_compositionEvaluations = $preNotBranchEvaluations; @@ -186,7 +217,13 @@ } } {% endif %} - $value = $proposedValue; + {# Array-side compositions (slotKey set) never produce a nested-instance + $proposedValue; writing $proposedValue back would replace the validated array + with null on whole-composition failure, suppressing a subsequent + unevaluatedItems check in collectErrors mode. #} + {% if not compositionValidator.getSlotKey() %} + $value = $proposedValue; + {% endif %} {% endif %} {% if generatorConfiguration.collectErrors() %} @@ -202,9 +239,13 @@ {% endif %} {% if compositionValidator.hasEvaluationTrackingEnabled() %} - if ($result) { - $this->_compositionEvaluations = $originalCompositionEvaluations; - } + {% if compositionValidator.getSlotKey() %} + $this->_compositionAnnotated[$slotKey] = $result ? [] : $compositionEvaluatedIndices; + {% else %} + if ($result) { + $this->_compositionEvaluations = $originalCompositionEvaluations; + } + {% endif %} {% endif %} return $result; diff --git a/src/Templates/Validator/ConditionalComposedItem.phptpl b/src/Templates/Validator/ConditionalComposedItem.phptpl index b4691edf..20c68f00 100644 --- a/src/Templates/Validator/ConditionalComposedItem.phptpl +++ b/src/Templates/Validator/ConditionalComposedItem.phptpl @@ -9,15 +9,22 @@ &$validatorIndex ) { $originalModelData = $value; + + {% if compositionValidator.getSlotKey() %} + $slotKey = '{{ compositionValidator.getSlotKey() }}'; + $compositionEvaluatedIndices = []; + {% endif %} {% if generatorConfiguration.collectErrors() %} $originalErrorRegistry = $this->_errorRegistry; $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} - {% if ifProperty.getNestedSchema() %} - $capturedIfInstance = null; - {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and ifProperty.getNestedSchema() %} + $capturedIfInstance = null; + {% endif %} + + {% if compositionValidator.getSlotKey() and ifProperty.branchHasContains() %} + $branchContainsMatches = []; {% endif %} try { @@ -33,10 +40,8 @@ } {% endif %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} - {% if ifProperty.getNestedSchema() %} - $capturedIfInstance = is_object($value) ? $value : null; - {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and ifProperty.getNestedSchema() %} + $capturedIfInstance = is_object($value) ? $value : null; {% endif %} } catch (\Exception $e) { $ifException = $e; @@ -44,22 +49,46 @@ $value = $originalModelData; - {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and ifProperty.branchIsArrayKind() %} + if (!$ifException) { + {% if ifProperty.branchHasItemsSchema() %} + $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); + {% endif %} + {% if not ifProperty.branchHasItemsSchema() and ifProperty.getBranchTupleItemsCount() %} + $compositionEvaluatedIndices += array_fill( + 0, + min({{ ifProperty.getBranchTupleItemsCount() }}, count($originalModelData)), + true, + ); + {% endif %} + {% if not ifProperty.branchHasItemsSchema() and ifProperty.branchHasNonFalseAdditionalItems() %} + $compositionEvaluatedIndices += array_fill( + {{ ifProperty.getBranchTupleItemsCount() }}, + max(0, count($originalModelData) - {{ ifProperty.getBranchTupleItemsCount() }}), + true, + ); + {% endif %} + {% if ifProperty.branchHasContains() %} + $compositionEvaluatedIndices += $branchContainsMatches; + {% endif %} + } + {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} if ($ifException) { $this->_compositionEvaluations[$validatorIndex][0] = null; } else { {% if ifProperty.branchHasNonFalseAdditionalProperties() %} $this->_compositionEvaluations[$validatorIndex][0] = true; - {% else %} - {% if ifProperty.getNestedSchema() %} - $this->_compositionEvaluations[$validatorIndex][0] = $capturedIfInstance; - {% else %} - $this->_compositionEvaluations[$validatorIndex][0] = $this->collectEvaluatedProperties( - $modelData, - {{ ifProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, - {{ ifProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, - ); - {% endif %} + {% endif %} + {% if not ifProperty.branchHasNonFalseAdditionalProperties() and ifProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][0] = $capturedIfInstance; + {% endif %} + {% if not ifProperty.branchHasNonFalseAdditionalProperties() and not ifProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][0] = $this->collectEvaluatedProperties( + $modelData, + {{ ifProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, + {{ ifProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, + ); {% endif %} } {% endif %} @@ -70,10 +99,12 @@ if (!$ifException) { {% if thenProperty %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} - {% if thenProperty.getNestedSchema() %} - $capturedThenInstance = null; - {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and thenProperty.getNestedSchema() %} + $capturedThenInstance = null; + {% endif %} + + {% if compositionValidator.getSlotKey() and thenProperty.branchHasContains() %} + $branchContainsMatches = []; {% endif %} try { @@ -89,49 +120,72 @@ } {% endif %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} - {% if thenProperty.getNestedSchema() %} - $capturedThenInstance = is_object($value) ? $value : null; - {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and thenProperty.getNestedSchema() %} + $capturedThenInstance = is_object($value) ? $value : null; {% endif %} } catch (\Exception $e) { $thenException = $e; } - {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and thenProperty.branchIsArrayKind() %} + if (!$thenException) { + {% if thenProperty.branchHasItemsSchema() %} + $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); + {% endif %} + {% if not thenProperty.branchHasItemsSchema() and thenProperty.getBranchTupleItemsCount() %} + $compositionEvaluatedIndices += array_fill( + 0, + min({{ thenProperty.getBranchTupleItemsCount() }}, count($originalModelData)), + true, + ); + {% endif %} + {% if not thenProperty.branchHasItemsSchema() and thenProperty.branchHasNonFalseAdditionalItems() %} + $compositionEvaluatedIndices += array_fill( + {{ thenProperty.getBranchTupleItemsCount() }}, + max(0, count($originalModelData) - {{ thenProperty.getBranchTupleItemsCount() }}), + true, + ); + {% endif %} + {% if thenProperty.branchHasContains() %} + $compositionEvaluatedIndices += $branchContainsMatches; + {% endif %} + } + {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} if ($thenException) { $this->_compositionEvaluations[$validatorIndex][1] = null; } else { {% if thenProperty.branchHasNonFalseAdditionalProperties() %} $this->_compositionEvaluations[$validatorIndex][1] = true; - {% else %} - {% if thenProperty.getNestedSchema() %} - $this->_compositionEvaluations[$validatorIndex][1] = $capturedThenInstance; - {% else %} - $this->_compositionEvaluations[$validatorIndex][1] = $this->collectEvaluatedProperties( - $modelData, - {{ thenProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, - {{ thenProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, - ); - {% endif %} + {% endif %} + {% if not thenProperty.branchHasNonFalseAdditionalProperties() and thenProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][1] = $capturedThenInstance; + {% endif %} + {% if not thenProperty.branchHasNonFalseAdditionalProperties() and not thenProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][1] = $this->collectEvaluatedProperties( + $modelData, + {{ thenProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, + {{ thenProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, + ); {% endif %} } {% endif %} - {% else %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} - $this->_compositionEvaluations[$validatorIndex][1] = null; - {% endif %} + {% endif %} + {% if not thenProperty and compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + $this->_compositionEvaluations[$validatorIndex][1] = null; {% endif %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} $this->_compositionEvaluations[$validatorIndex][2] = null; {% endif %} } else { {% if elseProperty %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} - {% if elseProperty.getNestedSchema() %} - $capturedElseInstance = null; - {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and elseProperty.getNestedSchema() %} + $capturedElseInstance = null; + {% endif %} + + {% if compositionValidator.getSlotKey() and elseProperty.branchHasContains() %} + $branchContainsMatches = []; {% endif %} try { @@ -147,41 +201,62 @@ } {% endif %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} - {% if elseProperty.getNestedSchema() %} - $capturedElseInstance = is_object($value) ? $value : null; - {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and elseProperty.getNestedSchema() %} + $capturedElseInstance = is_object($value) ? $value : null; {% endif %} } catch (\Exception $e) { $elseException = $e; } - {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and elseProperty.branchIsArrayKind() %} + if (!$elseException) { + {% if elseProperty.branchHasItemsSchema() %} + $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); + {% endif %} + {% if not elseProperty.branchHasItemsSchema() and elseProperty.getBranchTupleItemsCount() %} + $compositionEvaluatedIndices += array_fill( + 0, + min({{ elseProperty.getBranchTupleItemsCount() }}, count($originalModelData)), + true, + ); + {% endif %} + {% if not elseProperty.branchHasItemsSchema() and elseProperty.branchHasNonFalseAdditionalItems() %} + $compositionEvaluatedIndices += array_fill( + {{ elseProperty.getBranchTupleItemsCount() }}, + max(0, count($originalModelData) - {{ elseProperty.getBranchTupleItemsCount() }}), + true, + ); + {% endif %} + {% if elseProperty.branchHasContains() %} + $compositionEvaluatedIndices += $branchContainsMatches; + {% endif %} + } + {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} if ($elseException) { $this->_compositionEvaluations[$validatorIndex][2] = null; } else { {% if elseProperty.branchHasNonFalseAdditionalProperties() %} $this->_compositionEvaluations[$validatorIndex][2] = true; - {% else %} - {% if elseProperty.getNestedSchema() %} - $this->_compositionEvaluations[$validatorIndex][2] = $capturedElseInstance; - {% else %} - $this->_compositionEvaluations[$validatorIndex][2] = $this->collectEvaluatedProperties( - $modelData, - {{ elseProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, - {{ elseProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, - ); - {% endif %} + {% endif %} + {% if not elseProperty.branchHasNonFalseAdditionalProperties() and elseProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][2] = $capturedElseInstance; + {% endif %} + {% if not elseProperty.branchHasNonFalseAdditionalProperties() and not elseProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][2] = $this->collectEvaluatedProperties( + $modelData, + {{ elseProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, + {{ elseProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, + ); {% endif %} } {% endif %} - {% else %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} - $this->_compositionEvaluations[$validatorIndex][2] = null; - {% endif %} + {% endif %} + {% if not elseProperty and compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + $this->_compositionEvaluations[$validatorIndex][2] = null; {% endif %} - {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} $this->_compositionEvaluations[$validatorIndex][1] = null; {% endif %} } @@ -218,5 +293,11 @@ } {% endif %} - return $thenException || $elseException; + $conditionalResult = $thenException || $elseException; + + {% if compositionValidator.getSlotKey() %} + $this->_compositionAnnotated[$slotKey] = $conditionalResult ? [] : $compositionEvaluatedIndices; + {% endif %} + + return $conditionalResult; })($value) diff --git a/src/Templates/Validator/NoUnevaluatedItems.phptpl b/src/Templates/Validator/NoUnevaluatedItems.phptpl index 7a5c6c84..b8a895c4 100644 --- a/src/Templates/Validator/NoUnevaluatedItems.phptpl +++ b/src/Templates/Validator/NoUnevaluatedItems.phptpl @@ -1,5 +1,5 @@ is_array($value) && ($unevaluatedItems = $this->collectUnevaluatedIndices( $value, '{{ arrayPropertyName }}', - {{ compositionValidatorKeys }}, + {{ compositionSlotKeys }}, )) diff --git a/src/Templates/Validator/UnevaluatedItems.phptpl b/src/Templates/Validator/UnevaluatedItems.phptpl index d4f6efc2..08abc4d2 100644 --- a/src/Templates/Validator/UnevaluatedItems.phptpl +++ b/src/Templates/Validator/UnevaluatedItems.phptpl @@ -6,7 +6,7 @@ is_array($value) && (function ($items) use (&$invalidItems, $modelData): bool { $unevaluatedIndices = $this->collectUnevaluatedIndices( $items, '{{ arrayPropertyName }}', - {{ compositionValidatorKeys }}, + {{ compositionSlotKeys }}, ); foreach ($unevaluatedIndices as $index) { diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 5b63a991..3b1c90ee 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -7,6 +7,7 @@ use PHPModelGenerator\Exception\Arrays\InvalidUnevaluatedItemsException; use PHPModelGenerator\Exception\Arrays\UniqueItemsException; use PHPModelGenerator\Exception\Arrays\UnevaluatedItemsException; +use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\Generic\InvalidTypeException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\GeneratorConfiguration; @@ -278,4 +279,228 @@ public function testInvalidTypeForKeywordThrowsSchemaException( $this->generateClassFromFile($schemaFile); } + + /** + * Composition branches contribute their evaluated indices to a sibling unevaluatedItems + * accumulator. The fixture has two tuple-form items branches under allOf: branch 1 covers + * index 0, branch 2 covers indices 0-1. When both succeed (string array), the union + * covers 0-1 and any tail index is reported as unevaluated. + */ + public function testAllOfBranchesContributeEvaluatedIndices(): void + { + $className = $this->generateClassFromFile('AllOfTupleBranches.json'); + + // Both branches succeed; union covers 0-1; no tail → accept. + $accepted = new $className(['tags' => ['alpha', 'beta']]); + $this->assertSame(['alpha', 'beta'], $accepted->getTags()); + + // Both branches succeed; union covers 0-1; index 2 is unevaluated → reject just index 2. + try { + new $className(['tags' => ['alpha', 'beta', 'gamma']]); + $this->fail('Expected UnevaluatedItemsException for tail index past union'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + 'Provided JSON for tags contains not allowed unevaluated items [#2]', + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } + } + + /** + * Regression guard for the chain-orchestration bug where composition failure assigned + * `$value = $proposedValue` (null) at IIFE end, causing the downstream + * `is_array($value)` gate inside `unevaluatedItems` to short-circuit and silently + * suppress its error in collectErrors mode. With the fix, both errors appear: the + * composition's AllOfException listing the failing branches and the + * UnevaluatedItemsException listing every index as unevaluated (composition contributed + * no claims on failure). + */ + public function testCompositionFailurePreservesArrayValueSoUnevaluatedItemsCheckStillFires(): void + { + $className = $this->generateClassFromFile( + 'AllOfTupleBranches.json', + (new GeneratorConfiguration())->setCollectErrors(true), + ); + + try { + new $className(['tags' => [1, 2]]); + $this->fail('Expected ErrorRegistryException combining composition + unevaluated errors'); + } catch (ErrorRegistryException $exception) { + $this->assertSame( + <<<'MSG' + Invalid value for tags declined by composition constraint. + Requires to match all composition elements but matched 0 elements. + - Composition element #1: Failed + * Invalid tuple item in array tags: + - invalid tuple #1 + * Invalid type for tuple item #0 of array tags. Requires string, got integer + - Composition element #2: Failed + * Invalid tuple item in array tags: + - invalid tuple #1 + * Invalid type for tuple item #0 of array tags. Requires string, got integer + - invalid tuple #2 + * Invalid type for tuple item #1 of array tags. Requires string, got integer + Provided JSON for tags contains not allowed unevaluated items [#0, #1] + MSG, + $exception->getMessage(), + ); + } + } + + /** + * Two properties on the same class — one array with composition + `unevaluatedItems: + * false`, one object with composition + `unevaluatedProperties: false` — exercise the + * structural separation between `_compositionAnnotated` (array side) and + * `_compositionEvaluations` (object side). Each accumulator reads from its own field, so + * cross-contamination between the two paths is impossible by construction. + */ + public function testArrayAndObjectPropertyCompositionsCoexistOnTheSameClass(): void + { + $className = $this->generateClassFromFile('ArrayAndObjectPropertyCompositionsCoexist.json'); + + $accepted = new $className([ + 'tags' => ['only'], + 'meta' => ['kind' => 'X'], + ]); + $this->assertSame(['only'], $accepted->getTags()); + $this->assertSame('X', $accepted->getMeta()->getKind()); + } + + public function testArrayPropertyUnevaluatedItemsRejectsTailIndexWhileObjectPropertyAccepts(): void + { + $className = $this->generateClassFromFile('ArrayAndObjectPropertyCompositionsCoexist.json'); + + $this->expectException(UnevaluatedItemsException::class); + $this->expectExceptionMessage('Provided JSON for tags contains not allowed unevaluated items [#1]'); + + new $className([ + 'tags' => ['head', 'tail'], + 'meta' => ['kind' => 'X'], + ]); + } + + /** + * Regression guard for the extracted-method-name collision: two compositions on the + * same property previously hashed to the same `_validateTags_ComposedProperty_` + * method (the hash was derived from the property's JSON alone), so the second + * registration overwrote the first and both call sites invoked the same body. With + * the fix mixing the validator's object hash into the method name, each composition + * keeps its own body and writes to its own slot key. + */ + public function testMultipleSiblingCompositionsContributeIndependently(): void + { + $className = $this->generateClassFromFile('MultipleCompositionsOnProperty.json'); + + // allOf branch (tuple-1) + oneOf branch (tuple-2) both succeed on two-string array. + // Union {0, 1} covers everything → accept. + $accepted = new $className(['tags' => ['a', 'b']]); + $this->assertSame(['a', 'b'], $accepted->getTags()); + + // Three-element string array: both branches still succeed (tuples allow extras); + // union is still {0, 1}; index 2 unevaluated. + try { + new $className(['tags' => ['a', 'b', 'c']]); + $this->fail('Expected UnevaluatedItemsException for tail past widest tuple'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + 'Provided JSON for tags contains not allowed unevaluated items [#2]', + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } + } + + /** + * Regression guard for the PropertyProxy clone reset: + * `CompositionPropertyDecorator::getOrderedValidators()` returns fresh + * `withProperty(...)` clones on every call. A previous fix attempt set + * `setTrackBranchMatches(true)` on the clones returned to the post-processor, leaving + * the validator instances actually emitted into generated code with the flag still + * false; contains-matched indices were silently dropped. With the fix iterating the + * wrapped property's source validators via `getWrappedProperty()`, the flag reaches + * the rendered instance. + * + * Three-element array `['a', 5, 'c']`: contains matches index 1 (integer). The branch + * claims {1}; sibling unevaluatedItems reports {0, 2} as unevaluated — not {0, 1, 2}. + */ + public function testContainsOnlyBranchClaimsMatchedIndex(): void + { + $className = $this->generateClassFromFile('ContainsOnlyBranch.json'); + + // All-integer array: contains matches every index; nothing left unevaluated. + $accepted = new $className(['tags' => [1, 2, 3]]); + $this->assertSame([1, 2, 3], $accepted->getTags()); + + // Mixed array: contains matches index 1; non-matching indices fail unevaluatedItems. + try { + new $className(['tags' => ['a', 5, 'c']]); + $this->fail('Expected UnevaluatedItemsException for non-matching indices'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + 'Provided JSON for tags contains not allowed unevaluated items [#0, #2]', + $exception->getMessage(), + ); + $this->assertSame([0, 2], $exception->getUnevaluatedItems()); + } + } + + /** + * Items + contains combined branch — tuple items claims index 0, contains matches its + * own indices, and the branch's evaluated set is the union of both. Items covers index 0 + * (string), contains matches index 1 (integer). Together they cover the whole two- + * element array — accept. + */ + public function testItemsPlusContainsBranchUnionsBothClaimSources(): void + { + $className = $this->generateClassFromFile('ItemsPlusContainsBranch.json'); + + // Items claims index 0, contains claims index 1 → union covers everything → accept. + $accepted = new $className(['tags' => ['head', 5]]); + $this->assertSame(['head', 5], $accepted->getTags()); + + // Three-element array: items covers 0, contains covers 1; index 2 is unevaluated. + try { + new $className(['tags' => ['head', 5, 'tail']]); + $this->fail('Expected UnevaluatedItemsException for unclaimed index'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + 'Provided JSON for tags contains not allowed unevaluated items [#2]', + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } + } + + /** + * Regression guard for the recursive activation walk in + * `UnevaluatedPropertiesPostProcessor::activateArrayComposition()`: a schema like + * `{type: array, allOf: [{$ref: "#/definitions/recursive"}], unevaluatedItems: false}` + * where the $ref resolves back to the same schema produces a composition validator + * whose composed branch's wrapped property carries the same composition validator + * instance. Without the cycle break, the walk recurses indefinitely; with it, the walk + * short-circuits on the second visit. + * + * The cycle-break guard was verified by hand: removing it from + * `activateArrayComposition()` and running this fixture causes the process to abort + * with `Xdebug has detected a possible infinite loop, and aborted your script with a + * stack depth of '512' frames`, with the offending frames pointing at the + * `activateArrayComposition` / `activateValidatorsInBranch` pair. With the guard + * restored, the walk terminates. + * + * The test is marked incomplete because end-to-end codegen still hits a *second* + * infinite recursion in `CompositionTypeHintDecorator::getTypeHint()` on the same + * fixture — a pre-existing bug in the type-hint computation path that has no cycle + * protection of its own. Fixing that is outside the unevaluatedItems work; the test + * will be promoted to a real assertion once the type-hint recursion is closed. + */ + public function testSelfReferencingArrayCompositionDoesNotRecurseIndefinitely(): void + { + $this->markTestIncomplete( + 'Activation-walk cycle break is in place and verified by hand. End-to-end ' + . 'codegen blocked by an unrelated infinite recursion in ' + . 'CompositionTypeHintDecorator::getTypeHint() on self-referencing schemas ' + . '(deferred bug; tracked in the implementation plan).', + ); + } } diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/AllOfTupleBranches.json b/tests/Schema/UnevaluatedItemsValidatorTest/AllOfTupleBranches.json new file mode 100644 index 00000000..86f111d7 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/AllOfTupleBranches.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "items": [ + { + "type": "string" + } + ] + }, + { + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ArrayAndObjectPropertyCompositionsCoexist.json b/tests/Schema/UnevaluatedItemsValidatorTest/ArrayAndObjectPropertyCompositionsCoexist.json new file mode 100644 index 00000000..2eefed8b --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ArrayAndObjectPropertyCompositionsCoexist.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "items": [ + { + "type": "string" + } + ] + } + ], + "unevaluatedItems": false + }, + "meta": { + "type": "object", + "allOf": [ + { + "properties": { + "kind": { + "type": "string" + } + } + } + ], + "unevaluatedProperties": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ContainsOnlyBranch.json b/tests/Schema/UnevaluatedItemsValidatorTest/ContainsOnlyBranch.json new file mode 100644 index 00000000..9f7ecb2d --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ContainsOnlyBranch.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "oneOf": [ + { + "contains": { + "type": "integer" + } + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ItemsPlusContainsBranch.json b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsPlusContainsBranch.json new file mode 100644 index 00000000..3f367b4b --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsPlusContainsBranch.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "oneOf": [ + { + "items": [ + { + "type": "string" + } + ], + "contains": { + "type": "integer" + } + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/MultipleCompositionsOnProperty.json b/tests/Schema/UnevaluatedItemsValidatorTest/MultipleCompositionsOnProperty.json new file mode 100644 index 00000000..f3c5d28b --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/MultipleCompositionsOnProperty.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "items": [ + { + "type": "string" + } + ] + } + ], + "oneOf": [ + { + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/SelfReferencingArrayComposition.json b/tests/Schema/UnevaluatedItemsValidatorTest/SelfReferencingArrayComposition.json new file mode 100644 index 00000000..270241cd --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/SelfReferencingArrayComposition.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "definitions": { + "recursive": { + "type": "array", + "allOf": [ + { + "$ref": "#/definitions/recursive" + } + ], + "unevaluatedItems": false + } + }, + "properties": { + "tags": { + "$ref": "#/definitions/recursive" + } + } +} From 6d2e0b5456779b4212cbaaf3eae8e67116d7df48 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 29 Jun 2026 10:51:12 +0200 Subject: [PATCH 12/51] Declare unevaluated tracking fields conditionally; add mutability tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post processor previously added `_compositionEvaluations` and `_compositionAnnotated` unconditionally on every activation-triggering schema, justified by a comment claiming the trait reads them on no-composition schemas and PHP 8.2 would deprecate the undeclared- property access. Both halves of that justification were wrong: the trait's reads go through `?? []`, which does not trigger the dynamic- property deprecation, and the read sites only execute when the caller passes composition slot keys — empty on no-composition schemas. The fields were padding every activation-triggering generated class with two unused arrays. Each field now follows the precedent set by `addEvaluatedPropertyKeysField` and is declared only when something will write to it. The two activation passes have been extracted into `activateSchemaLevelTracking` (handles the root-level composition activation and `_compositionEvaluations`) and `activateArrayPropertyTracking` (handles the array-property walk plus `_compositionAnnotated` and `_evaluatedItemIndices`); `process()` now just orchestrates them. - `_compositionEvaluations` — only when at least one `AbstractComposedPropertyValidator` at the schema level had evaluation tracking enabled. - `_compositionAnnotated` — only when the array-property activation walk actually enabled tracking on a property-level composition. - `_evaluatedItemIndices` — new field, declared only when at least one array property carries `unevaluatedItems` (the future write site is in `UnevaluatedItems.phptpl`; the field is empty until then). `UnevaluatedItemsMutabilityTest` exercises what Stage 5.3's transient- field design relies on without any new validator wiring: the whole-array setter rebuilds composition annotations against the candidate array and re-runs `unevaluatedItems` against it (rejecting an array whose tail index no branch claims, preserving pre-setter state on rejection); `populate()` with array data of a different length flows through `PopulatePostProcessor`'s revalidation hook and behaves the same. Both tests use `setCollectErrors(false)` so the raw `UnevaluatedItemsException` surfaces directly, mirroring the existing `UnevaluatedPropertiesMutability Test` pattern. Co-Authored-By: Claude Opus 4.7 --- .../UnevaluatedPropertiesPostProcessor.php | 100 +++++++++++++---- .../Basic/UnevaluatedItemsMutabilityTest.php | 102 ++++++++++++++++++ .../AllOfTupleBranches.json | 28 +++++ 3 files changed, 209 insertions(+), 21 deletions(-) create mode 100644 tests/Basic/UnevaluatedItemsMutabilityTest.php create mode 100644 tests/Schema/UnevaluatedItemsMutabilityTest/AllOfTupleBranches.json diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php index bdd38741..e7f6a690 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -90,37 +90,60 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu // declare allOf/anyOf/oneOf/if-then-else. $schema->addTrait(CompositionEvaluationTrait::class); - // The cache field is only written when there are composition validators, but the trait - // reads $this->_compositionEvaluations even on no-composition schemas. Always declare the - // property so the read never touches an undefined property (deprecated in PHP 8.2+). - $schema->addProperty( - (new Property( - 'compositionEvaluations', - new PropertyType('array'), - new JsonSchema(__FILE__, []), - )) - ->setInternal(true) - ->setDefaultValue([]), - ); + $this->activateSchemaLevelTracking($schema); + $this->activateArrayPropertyTracking($schema); + } + + /** + * Enables evaluation tracking on every schema-level composition validator and declares + * the `_compositionEvaluations` cache field if any composition was activated. + * + * Each activated branch writes its success bit and the property names it claimed into + * `_compositionEvaluations[$validatorIndex][$componentIndex]`. The unevaluatedProperties + * validator reads those slots via the trait's `collectUnevaluatedKeys`. Schemas without + * any composition skip the field declaration — the trait's reads use `?? []`, so the + * absence is safe. + */ + private function activateSchemaLevelTracking(Schema $schema): void + { + $activated = false; + foreach ($schema->getBaseValidators() as $baseValidator) { + if ($baseValidator instanceof AbstractComposedPropertyValidator) { + $baseValidator->enableEvaluationTracking(); + $activated = true; + } + } + + if (!$activated) { + return; + } - // Transient bridge between property-level array compositions and a sibling - // unevaluatedItems validator. Wholesale-overwritten per chain run; never registered - // with the rollback registry, never snapshotted across setter calls. $schema->addProperty( (new Property( - 'compositionAnnotated', + 'compositionEvaluations', new PropertyType('array'), new JsonSchema(__FILE__, []), )) ->setInternal(true) ->setDefaultValue([]), ); + } - foreach ($schema->getBaseValidators() as $baseValidator) { - if ($baseValidator instanceof AbstractComposedPropertyValidator) { - $baseValidator->enableEvaluationTracking(); - } - } + /** + * Walks array properties carrying `unevaluatedItems`, activates evaluation tracking on + * any composition validator attached to such a property, and declares the two transient + * array-side fields when their respective write sites are reachable: + * - `_compositionAnnotated` — only when at least one property-level composition was + * activated. The composition template wholesale-overwrites the property's slot at + * the end of every chain run. + * - `_evaluatedItemIndices` — whenever at least one array property carries + * `unevaluatedItems`. The unevaluatedItems template writes a `[propertyName => + * [index => true]]` entry after each successful per-index validation. + */ + private function activateArrayPropertyTracking(Schema $schema): void + { + $unevaluatedItemsPresent = false; + $compositionActivated = false; foreach ($schema->getProperties() as $schemaProperty) { $propertyJson = $schemaProperty->getJsonSchema()->getJson(); @@ -138,15 +161,50 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu continue; } + $unevaluatedItemsPresent = true; + $this->slotKeyCounter = 0; $this->activatedCompositions = []; foreach ($schemaProperty->getOrderedValidators() as $validator) { if ($validator instanceof AbstractComposedPropertyValidator) { $this->activateArrayComposition($validator, $schemaProperty); + $compositionActivated = true; } } } + + if ($compositionActivated) { + // Transient bridge between property-level array compositions and a sibling + // unevaluatedItems validator. Wholesale-overwritten per chain run; never registered + // with the rollback registry, never snapshotted across setter calls. + $schema->addProperty( + (new Property( + 'compositionAnnotated', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + )) + ->setInternal(true) + ->setDefaultValue([]), + ); + } + + if ($unevaluatedItemsPresent) { + // Per-array-property index map of indices the property's UnevaluatedItems validator + // successfully evaluated, shaped as [propertyName => [index => true]]. Inner and outer + // UnevaluatedItems validators share the same instance field — there are no nested + // array classes to cross. Transient: every chain run overwrites it; never registered + // with the rollback registry, never snapshotted across setter calls. + $schema->addProperty( + (new Property( + 'evaluatedItemIndices', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + )) + ->setInternal(true) + ->setDefaultValue([]), + ); + } } /** diff --git a/tests/Basic/UnevaluatedItemsMutabilityTest.php b/tests/Basic/UnevaluatedItemsMutabilityTest.php new file mode 100644 index 00000000..396628df --- /dev/null +++ b/tests/Basic/UnevaluatedItemsMutabilityTest.php @@ -0,0 +1,102 @@ +generateClassFromFile( + 'AllOfTupleBranches.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['tags' => ['alpha', 'beta']]); + + try { + $object->setTags(['alpha', 'beta', 'gamma']); + $this->fail('Expected setTags to throw because index 2 is unevaluated'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + 'Provided JSON for tags contains not allowed unevaluated items [#2]', + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } + $this->assertSame(['alpha', 'beta'], $object->getTags()); + $this->assertSame(['tags' => ['alpha', 'beta']], $object->meta()->rawInput()); + + $object->setTags(['solo']); + $this->assertSame(['solo'], $object->getTags()); + $this->assertSame(['tags' => ['solo']], $object->meta()->rawInput()); + } + + /** + * populate() with array data that changes the array's length: + * - a longer array introduces a tail index neither branch covers; populate must throw + * and roll the model back to its pre-populate state; + * - a shorter array (or the same length) whose indices are all covered succeeds and + * the new value is observable. + */ + public function testPopulateLengthChangeRevalidatesUnevaluatedItems(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new PopulatePostProcessor()); + }; + + $className = $this->generateClassFromFile( + 'AllOfTupleBranches.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['tags' => ['alpha', 'beta']]); + + try { + $object->populate(['tags' => ['alpha', 'beta', 'gamma']]); + $this->fail('Expected populate to throw because index 2 is unevaluated'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + 'Provided JSON for tags contains not allowed unevaluated items [#2]', + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } + $this->assertSame(['alpha', 'beta'], $object->getTags()); + $this->assertSame(['tags' => ['alpha', 'beta']], $object->meta()->rawInput()); + + $object->populate(['tags' => ['solo']]); + $this->assertSame(['solo'], $object->getTags()); + $this->assertSame(['tags' => ['solo']], $object->meta()->rawInput()); + } +} diff --git a/tests/Schema/UnevaluatedItemsMutabilityTest/AllOfTupleBranches.json b/tests/Schema/UnevaluatedItemsMutabilityTest/AllOfTupleBranches.json new file mode 100644 index 00000000..86f111d7 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsMutabilityTest/AllOfTupleBranches.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "items": [ + { + "type": "string" + } + ] + }, + { + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + ], + "unevaluatedItems": false + } + } +} From c1ed414130dedf2e8d1f3d0e6530ed32cdc5556d Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Wed, 1 Jul 2026 12:20:27 +0200 Subject: [PATCH 13/51] Propagate branch-level unevaluatedItems claims to outer accumulator The unevaluatedItems template now writes _evaluatedItemIndices per successfully-validated index and snapshots/restores the field around the IIFE so a nested unevaluatedItems inside a composition branch contributes its claims to the enclosing accumulator on success and leaves no partial writes behind on failure. Co-Authored-By: Claude Opus 4.7 --- .../Validator/UnevaluatedItems.phptpl | 9 +++ tests/Basic/UnevaluatedItemsValidatorTest.php | 61 +++++++++++++++++++ .../InnerUnevaluatedItemsInBranch.json | 16 +++++ 3 files changed, 86 insertions(+) create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsInBranch.json diff --git a/src/Templates/Validator/UnevaluatedItems.phptpl b/src/Templates/Validator/UnevaluatedItems.phptpl index 08abc4d2..3d77ba83 100644 --- a/src/Templates/Validator/UnevaluatedItems.phptpl +++ b/src/Templates/Validator/UnevaluatedItems.phptpl @@ -2,6 +2,7 @@ is_array($value) && (function ($items) use (&$invalidItems, $modelData): bool { {% if generatorConfiguration.collectErrors() %} $originalErrorRegistry = $this->_errorRegistry; {% endif %} + $rollbackEvaluatedItemIndices = $this->_evaluatedItemIndices; $unevaluatedIndices = $this->collectUnevaluatedIndices( $items, @@ -29,6 +30,10 @@ is_array($value) && (function ($items) use (&$invalidItems, $modelData): bool { continue; } {% endif %} + + // The index validated against the unevaluatedItems subschema — record it so an + // enclosing schema's unevaluatedItems accumulator can credit it. + $this->_evaluatedItemIndices['{{ arrayPropertyName }}'][$index] = true; } catch (\Exception $e) { isset($invalidItems[$index]) ? $invalidItems[$index][] = $e @@ -40,5 +45,9 @@ is_array($value) && (function ($items) use (&$invalidItems, $modelData): bool { $this->_errorRegistry = $originalErrorRegistry; {% endif %} + if (!empty($invalidItems)) { + $this->_evaluatedItemIndices = $rollbackEvaluatedItemIndices; + } + return !empty($invalidItems); })($value) diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 3b1c90ee..014b8149 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -503,4 +503,65 @@ public function testSelfReferencingArrayCompositionDoesNotRecurseIndefinitely(): . '(deferred bug; tracked in the implementation plan).', ); } + + /** + * Spec propagation: a nested `unevaluatedItems` schema-form validator inside a + * successful `allOf` branch must contribute its claimed indices to the enclosing + * `unevaluatedItems` accumulator. The inner validator writes + * `_evaluatedItemIndices['tags'][$index] = true` after each successful per-index + * validation; the outer reads that map via `collectUnevaluatedIndices()`. + * + * Without the inner write, the outer `false` form would see every index as + * unevaluated and reject a perfectly valid all-string array; with the write, the + * outer credits the indices the inner already cleared. + */ + public function testInnerUnevaluatedItemsInAllOfBranchPropagatesIndicesToOuterAccumulator(): void + { + $className = $this->generateClassFromFile('InnerUnevaluatedItemsInBranch.json'); + + // Both indices validate as strings against the inner schema; inner writes + // [0, 1] into _evaluatedItemIndices['tags']; outer's false form sees zero + // unevaluated indices → accept. + $accepted = new $className(['tags' => ['alpha', 'beta']]); + $this->assertSame(['alpha', 'beta'], $accepted->getTags()); + } + + /** + * Snapshot/restore guard: when the nested `unevaluatedItems` inside an `allOf` + * branch FAILS (any per-index check yields an invalid item), the inner template + * must restore `_evaluatedItemIndices` to its pre-IIFE state so no partial writes + * leak to the outer accumulator. Under `collectErrors` mode the chain continues + * past the failing composition so the outer `false` form runs against the + * snapshot-restored state — if the rollback worked, every index appears + * unevaluated and is reported in the outer error. + * + * Without the snapshot/restore, indices 0 and 2 (the successfully-validated + * string entries) would leak into `_evaluatedItemIndices['tags']` and the outer + * would mistakenly report only index 1 as unevaluated. + */ + public function testInnerUnevaluatedItemsFailureRollsBackPartialWritesUnderCollectErrors(): void + { + $className = $this->generateClassFromFile( + 'InnerUnevaluatedItemsInBranch.json', + (new GeneratorConfiguration())->setCollectErrors(true), + ); + + try { + new $className(['tags' => ['alpha', 42, 'gamma']]); + $this->fail('Expected ErrorRegistryException combining composition + outer unevaluated errors'); + } catch (ErrorRegistryException $exception) { + $this->assertSame( + <<<'MSG' + Invalid value for tags declined by composition constraint. + Requires to match all composition elements but matched 0 elements. + - Composition element #1: Failed + * Invalid unevaluated items in array tags: + - invalid unevaluated item #1 + * Invalid type for unevaluated item. Requires string, got integer + Provided JSON for tags contains not allowed unevaluated items [#0, #1, #2] + MSG, + $exception->getMessage(), + ); + } + } } diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsInBranch.json b/tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsInBranch.json new file mode 100644 index 00000000..ee64468f --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsInBranch.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "unevaluatedItems": { + "type": "string" + } + } + ], + "unevaluatedItems": false + } + } +} From b7ce691c67e5e6b6bc5f3007147f5c3a6d7c7627 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 2 Jul 2026 14:11:15 +0200 Subject: [PATCH 14/51] Add unevaluatedProperties $ref and recursion test coverage Verifies the activation walk already handles $ref-resolved schemas through the property tree it traverses (via getNestedSchema on properties and composition branches). No generator changes required. Coverage: - unevaluatedProperties expressed as {$ref: "#/$defs/foo"} - $ref-resolved allOf branch contributes annotations to outer accumulator - Recursive self-$ref terminates and enforces unevaluatedProperties at every nesting depth (validated with full-message assertions unwrapping through NestedObjectException::getNestedException) - External $ref to ExternalSchema placeholder used as composition branch: markTestIncomplete, blocked by pre-existing composition-processor requirement that every composed branch surface a nested schema (same limitation as the array-side self-referencing test) Co-Authored-By: Claude Opus 4.7 --- .../UnevaluatedPropertiesValidatorTest.php | 196 ++++++++++++++++++ .../ExternalRefBranch.json | 14 ++ .../RecursiveSelfReference.json | 22 ++ ...fResolvedBranchContributesAnnotations.json | 27 +++ .../UnevaluatedIsRef.json | 16 ++ .../externalBranch.json | 11 + 6 files changed, 286 insertions(+) create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalRefBranch.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/RecursiveSelfReference.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/RefResolvedBranchContributesAnnotations.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/UnevaluatedIsRef.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest_external/externalBranch.json diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index 672bee78..15049a24 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -6,6 +6,7 @@ use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\Object\InvalidUnevaluatedPropertiesException; +use PHPModelGenerator\Exception\Object\NestedObjectException; use PHPModelGenerator\Exception\Object\RequiredValueException; use PHPModelGenerator\Exception\Object\UnevaluatedPropertiesException; use PHPModelGenerator\Model\GeneratorConfiguration; @@ -22,6 +23,8 @@ #[ApplicableDrafts(from: JsonSchemaDraft::DRAFT_2019_09)] class UnevaluatedPropertiesValidatorTest extends AbstractPHPModelGeneratorTestCase { + protected const EXTERNAL_JSON_DIRECTORIES = ['../UnevaluatedPropertiesValidatorTest_external']; + /** * Accepted input — the generated class must construct and round-trip the input through * `meta()->rawInput()` unchanged. One data row per scenario. @@ -409,4 +412,197 @@ public function testBranchLevelAdditionalPropertiesFeedsEvaluatedSetThroughCompo $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); } } + + /** + * The `unevaluatedProperties` keyword may be expressed as an inline `$ref` — the runtime + * shape is a schema, so the ref-resolved schema must be applied to every extra key. Two + * assertions on the same generated class: + * - `{name: "Alice", count: 42}` accepts because 42 satisfies the resolved integer + * schema. + * - `{name: "Alice", count: "not-int"}` rejects because the value fails the resolved + * integer schema. + */ + public function testUnevaluatedPropertiesReferencedViaRef(): void + { + $className = $this->generateClassFromFile('UnevaluatedIsRef.json'); + + $accepted = new $className(['name' => 'Alice', 'count' => 42]); + $this->assertSame(['name' => 'Alice', 'count' => 42], $accepted->meta()->rawInput()); + + try { + new $className(['name' => 'Alice', 'count' => 'not-int']); + $this->fail('$ref-resolved unevaluatedProperties schema must reject non-integer extra'); + } catch (InvalidUnevaluatedPropertiesException $exception) { + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains invalid unevaluated properties/', + $exception->getMessage(), + ); + } + } + + /** + * A composition branch expressed via `$ref` resolves to the referenced schema at generation + * time. Its `properties` declarations must contribute to the outer accumulator so an outer + * `unevaluatedProperties: false` sees the ref-resolved keys as evaluated. Two assertions + * on the same generated class: + * - `{name: "Alice", foo: "hi", bar: 5}` accepts — the ref-resolved allOf branch claims + * `foo` and `bar`; the local `properties` claims `name`; no unevaluated keys remain. + * - `{name: "Alice", stray: 1}` rejects — `stray` is not declared anywhere. + */ + public function testRefResolvedBranchContributesAnnotations(): void + { + $className = $this->generateClassFromFile('RefResolvedBranchContributesAnnotations.json'); + + $accepted = new $className(['name' => 'Alice', 'foo' => 'hi', 'bar' => 5]); + $this->assertSame( + ['name' => 'Alice', 'foo' => 'hi', 'bar' => 5], + $accepted->meta()->rawInput(), + ); + + try { + new $className(['name' => 'Alice', 'stray' => 1]); + $this->fail('An undeclared key must be rejected by unevaluatedProperties: false'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertMatchesRegularExpression( + '/^Provided JSON for \S+ contains not allowed unevaluated properties \[stray\]$/', + $exception->getMessage(), + ); + $this->assertSame(['stray'], $exception->getUnevaluatedProperties()); + } + } + + /** + * A schema that references itself through `$defs` must not cause the activation walk to + * recurse indefinitely — the walk's `seenSchemas` map is required (not defensive) for + * termination. This is the object-side analogue of the array-side self-reference test. + * + * Three scenarios on the same generated class: + * - `{root: {name: "n", child: {name: "n2"}}}` accepts because the recursive node's + * `unevaluatedProperties: false` sees only the declared `name` / `child` keys at + * every level. + * - A stray key one level down (`root.stray`) surfaces as a `NestedObjectException` + * wrapping the inner `UnevaluatedPropertiesException`. Unwrapping through + * `getNestedException()` confirms the inner exception's identity and its + * `getUnevaluatedProperties()` return value. + * - A stray key two levels down (`root.child.stray`) proves the same enforcement + * applies to the recursive `child` slot — meaning the `$ref`-resolved schema + * produces a working `unevaluatedProperties` validator at every nesting depth, + * not only at the outer entry point. + */ + public function testRecursiveSelfReferenceTerminates(): void + { + $className = $this->generateClassFromFile('RecursiveSelfReference.json'); + + $accepted = new $className([ + 'root' => [ + 'name' => 'outer', + 'child' => ['name' => 'inner'], + ], + ]); + $this->assertSame( + [ + 'root' => [ + 'name' => 'outer', + 'child' => ['name' => 'inner'], + ], + ], + $accepted->meta()->rawInput(), + ); + + // The recursive Node class is generated once and shared by both `root` and `child` + // (SchemaDefinitionDictionary caches by pointer). Resolve its class name for the + // exception-message assertions below. + $generationDir = dirname((new \ReflectionClass($className))->getFileName()); + $nestedFiles = array_values(array_filter( + glob($generationDir . DIRECTORY_SEPARATOR . '*.php'), + static fn(string $path): bool => str_contains(basename($path), $className . '_'), + )); + $nodeClassName = str_replace('.php', '', basename($nestedFiles[0])); + + try { + new $className([ + 'root' => [ + 'name' => 'outer', + 'stray' => 1, + ], + ]); + $this->fail('unevaluatedProperties: false on the recursive node must reject stray'); + } catch (NestedObjectException $exception) { + $innerException = $exception->getNestedException(); + $this->assertInstanceOf(UnevaluatedPropertiesException::class, $innerException); + $this->assertSame(['stray'], $innerException->getUnevaluatedProperties()); + + $this->assertSame( + <<getMessage(), + ); + } + + try { + new $className([ + 'root' => [ + 'name' => 'outer', + 'child' => [ + 'name' => 'inner', + 'stray' => 1, + ], + ], + ]); + $this->fail( + 'unevaluatedProperties: false must still enforce at the recursively referenced ' + . 'child level', + ); + } catch (NestedObjectException $exception) { + // Two-level unwrap: the outer NestedObjectException wraps a + // NestedObjectException for `child`, which in turn wraps the + // UnevaluatedPropertiesException that flagged `stray`. + $childException = $exception->getNestedException(); + $this->assertInstanceOf(NestedObjectException::class, $childException); + + $innerException = $childException->getNestedException(); + $this->assertInstanceOf(UnevaluatedPropertiesException::class, $innerException); + $this->assertSame(['stray'], $innerException->getUnevaluatedProperties()); + + $this->assertSame( + <<getMessage(), + ); + } + } + + /** + * A `$ref` that resolves to a schema in a file outside the provider's base directory is + * represented at generation time as an `ExternalSchema` placeholder — a class the + * generator does not emit validation for. When such a placeholder is used as a composition + * branch, the design records that the branch's evaluated set is treated as covering every + * instance key, because we trust the external schema to enforce its own contract. + * + * End-to-end codegen is blocked today by the composition processor's insistence that every + * composed branch surface a nested schema. An `ExternalSchema` placeholder branch has + * `getNestedSchema() === null`, so SchemaProcessor throws before the unevaluated wiring + * ever runs. The same pre-existing limitation blocks a related test on the array-side + * self-referencing fixture. Fixing it lives in the composition processor's + * `shouldSkip()` / nested-schema expectation, which is out of scope for this topic. + * + * The fixture is kept in the test directory to document the intended behaviour and to + * accelerate the promotion of this case to a real assertion once the composition + * processor's nested-schema expectation is relaxed. + */ + public function testExternalRefBranchTreatsAllKeysAsEvaluated(): void + { + $this->markTestIncomplete( + 'End-to-end codegen blocked by the composition processor\'s requirement that ' + . 'every composed branch surface a nested schema. An ExternalSchema placeholder ' + . 'branch has no nested schema, so generation fails before the unevaluated ' + . 'accumulator sees the branch. The fix belongs to the composition processor ' + . '(same pre-existing bug that blocks the array-side self-referencing test).', + ); + } } diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalRefBranch.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalRefBranch.json new file mode 100644 index 00000000..c40757e2 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalRefBranch.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "$ref": "../UnevaluatedPropertiesValidatorTest_external/externalBranch.json#/definitions/extension" + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/RecursiveSelfReference.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/RecursiveSelfReference.json new file mode 100644 index 00000000..a2a4faad --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/RecursiveSelfReference.json @@ -0,0 +1,22 @@ +{ + "$defs": { + "node": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "child": { + "$ref": "#/$defs/node" + } + }, + "unevaluatedProperties": false + } + }, + "type": "object", + "properties": { + "root": { + "$ref": "#/$defs/node" + } + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/RefResolvedBranchContributesAnnotations.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/RefResolvedBranchContributesAnnotations.json new file mode 100644 index 00000000..df2849f5 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/RefResolvedBranchContributesAnnotations.json @@ -0,0 +1,27 @@ +{ + "$defs": { + "extension": { + "type": "object", + "properties": { + "foo": { + "type": "string" + }, + "bar": { + "type": "integer" + } + } + } + }, + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "$ref": "#/$defs/extension" + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/UnevaluatedIsRef.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/UnevaluatedIsRef.json new file mode 100644 index 00000000..fad4c1be --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/UnevaluatedIsRef.json @@ -0,0 +1,16 @@ +{ + "$defs": { + "integerValue": { + "type": "integer" + } + }, + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "$ref": "#/$defs/integerValue" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest_external/externalBranch.json b/tests/Schema/UnevaluatedPropertiesValidatorTest_external/externalBranch.json new file mode 100644 index 00000000..9736250c --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest_external/externalBranch.json @@ -0,0 +1,11 @@ +{ + "definitions": { + "extension": { + "properties": { + "external": { + "type": "string" + } + } + } + } +} From 3df6eac8e07913b8d7d27172100eff1e379edf7e Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 6 Jul 2026 11:13:53 +0200 Subject: [PATCH 15/51] Coordinate unevaluated + conditional composition with RFC 6901 stamping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge from jsonSchemaDraft2019 landed pointer stamping on every validator factory but surfaced two follow-up gaps in the unevaluated work. ConditionalPropertyValidator did not update templateValues['compositionValidator'] at render time. withJsonPointer() clones the validator, but the clone's template still pointed at the pre-clone original whose evaluationTrackingEnabled flag is false. Every if/then/else composition consequently emitted no _compositionEvaluations writes, so an if-branch that claimed a property left the outer unevaluatedProperties accumulator empty and the key was reported as unevaluated. Late-bind in getCheck() the same way ComposedPropertyValidator already does. Neither unevaluated factory (object nor array) chained ->withJsonPointer() on the emitted validator, so UnevaluatedItems / UnevaluatedProperties / InvalidUnevaluated* exceptions carried an empty pointer. SetUnevaluatedProperty.phptpl's two RegularPropertyAsUnevaluated throw-sites emitted no pointer at all. Wire real pointers through three shapes: - Factory-level: /path/to/property/unevaluatedItems and /unevaluatedProperties, matching the sibling stamping in additional/pattern factories. - Object-property harvest (root declaration): resolved via each property's synthesized #[JsonPointer] attribute, matching how AdditionalPropertiesAccessorPostProcessor resolves it. - Composition-branch harvest (inline branch declaration): walked recursively with per-branch pointers, so a property declared inside /allOf/0 gets /allOf/0/properties/. Pattern-based entries carry their /patternProperties/ pointer verbatim. Pointer utilities extracted to Utils\JsonSchema. encodePointer and decodePointer moved off the Model\SchemaDefinition\JsonSchema data class where they had been static bystanders; resolvePrimaryJsonPointer extracted from the two accessor post processors that had duplicated it. All call sites aliased-import as JsonSchemaUtil to avoid a name clash with the model class. RenderHelper::varExportPcrePatternMap added so a template can foreach over a regex => pointer map when the pattern-guard needs both fields at runtime. Pointer assertions added to the acceptance tests that already caught the message text and payload. The pattern-focused accessor test's fixture regex changed from `^x_` to `^s/~` so the emitted pointer becomes `/patternProperties/^s~1~0`, exercising both RFC 6901 replacements and their ordering in a single assertion. The three RegularPropertyAsUnevaluatedPropertyException catches now also assert the full "Couldn't add regular property … as unevaluated property to object …" message. Requires the coordinating production-library commit 7bfa040 (jsonPointer arg added to UnevaluatedItems / UnevaluatedProperties / RegularPropertyAsUnevaluatedProperty / MinContains / MaxContains). Push that commit and \`composer update\` to lock the new SHA. Co-Authored-By: Claude Opus 4.7 --- src/Model/SchemaDefinition/JsonSchema.php | 13 +-- .../SchemaDefinitionDictionary.php | 5 +- .../ConditionalPropertyValidator.php | 6 +- .../UnevaluatedItemsValidatorFactory.php | 7 +- .../PatternPropertiesValidatorFactory.php | 8 +- .../Object/PropertiesValidatorFactory.php | 9 +- .../UnevaluatedPropertiesValidatorFactory.php | 8 +- .../Factory/SimpleBaseValidatorFactory.php | 3 +- .../SimplePropertyValidatorFactory.php | 3 +- .../ForbiddenPatternPropertiesValidator.php | 4 +- ...itionalPropertiesAccessorPostProcessor.php | 29 +---- .../SetUnevaluatedProperty.phptpl | 13 ++- ...aluatedPropertiesAccessorPostProcessor.php | 105 ++++++++++++------ src/Utils/JsonSchema.php | 57 ++++++++++ src/Utils/RenderHelper.php | 18 +++ tests/Basic/UnevaluatedItemsValidatorTest.php | 15 +++ .../UnevaluatedPropertiesValidatorTest.php | 6 + ...tedPropertiesAccessorPostProcessorTest.php | 64 +++++++++-- .../PatternAndUnevaluatedCoexist.json | 2 +- 19 files changed, 267 insertions(+), 108 deletions(-) create mode 100644 src/Utils/JsonSchema.php diff --git a/src/Model/SchemaDefinition/JsonSchema.php b/src/Model/SchemaDefinition/JsonSchema.php index e4c8f62c..e8af5a53 100644 --- a/src/Model/SchemaDefinition/JsonSchema.php +++ b/src/Model/SchemaDefinition/JsonSchema.php @@ -7,6 +7,7 @@ use PHPModelGenerator\Exception\GeneratorException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Utils\ArrayHash; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; /** * Class JsonSchema @@ -121,7 +122,7 @@ public function navigate(string | int $pointer): JsonSchema foreach (explode('/', $trimmed) as $pathSegment) { $jsonSchema->pointer .= "/$pathSegment"; - $decodedPathSegment = self::decodePointer($pathSegment); + $decodedPathSegment = JsonSchemaUtil::decodePointer($pathSegment); if (!array_key_exists($decodedPathSegment, $jsonSchema->json)) { throw new SchemaException("Unresolved path segment $pathSegment in file $this->file"); @@ -142,14 +143,4 @@ public function getPointer(): string { return $this->pointer; } - - public static function encodePointer(string | int $pointer): string - { - return str_replace(['~', '/'], ['~0', '~1'], (string) $pointer); - } - - public static function decodePointer(string | int $pointer): string - { - return str_replace(['~1', '~0'], ['/', '~'], (string) $pointer); - } } diff --git a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php index d71ddce8..2045aa89 100644 --- a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php +++ b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php @@ -8,6 +8,7 @@ use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; /** * Class SchemaDefinitionDictionary @@ -47,7 +48,7 @@ public function setUpDefinitionDictionary(SchemaProcessor $schemaProcessor, Sche $this->addDefinition( $key, new SchemaDefinition( - $schema->getJsonSchema()->navigate(JsonSchema::encodePointer($key)), + $schema->getJsonSchema()->navigate(JsonSchemaUtil::encodePointer($key)), $schemaProcessor, $schema, ), @@ -80,7 +81,7 @@ protected function fetchDefinitionsById( } $this->fetchDefinitionsById( - $jsonSchema->navigate(JsonSchema::encodePointer($key)), + $jsonSchema->navigate(JsonSchemaUtil::encodePointer($key)), $schemaProcessor, $schema, ); diff --git a/src/Model/Validator/ConditionalPropertyValidator.php b/src/Model/Validator/ConditionalPropertyValidator.php index f43d97c8..a42ac107 100644 --- a/src/Model/Validator/ConditionalPropertyValidator.php +++ b/src/Model/Validator/ConditionalPropertyValidator.php @@ -48,8 +48,6 @@ public function __construct( $this->conditionBranches = $conditionBranches; $this->thenBranch = $validatorVariables['thenProperty'] ?? null; $this->elseBranch = $validatorVariables['elseProperty'] ?? null; - - $this->templateValues['compositionValidator'] = $this; } /** @@ -98,6 +96,10 @@ public function getElseBranch(): ?CompositionPropertyDecorator */ public function getCheck(): string { + // Late-bind `compositionValidator` so template guards see the flags set on the current + // clone rather than on the pre-`withJsonPointer()` original. + $this->templateValues['compositionValidator'] = $this; + $this->setupBranchDefaultHelpers(); $thenProperty = $this->templateValues['thenProperty'] ?? null; diff --git a/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php index 61ac20d9..e7414db0 100644 --- a/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php +++ b/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php @@ -57,9 +57,11 @@ public function modify( return; } + $unevaluatedPointer = $propertySchema->getPointer() . '/' . $this->key; + if ($unevaluatedItems === false) { $property->addValidator( - new NoUnevaluatedItemsValidator($property), + (new NoUnevaluatedItemsValidator($property))->withJsonPointer($unevaluatedPointer), self::VALIDATOR_PRIORITY, ); @@ -67,7 +69,8 @@ public function modify( } $property->addValidator( - new UnevaluatedItemsValidator($schemaProcessor, $schema, $property, $propertySchema), + (new UnevaluatedItemsValidator($schemaProcessor, $schema, $property, $propertySchema)) + ->withJsonPointer($unevaluatedPointer), self::VALIDATOR_PRIORITY, ); } diff --git a/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php index db6df515..fc4dcc63 100644 --- a/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php @@ -12,6 +12,7 @@ use PHPModelGenerator\Model\Validator\ForbiddenPatternPropertiesValidator; use PHPModelGenerator\Model\Validator\PatternPropertiesValidator; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; class PatternPropertiesValidatorFactory extends AbstractValidatorFactory { @@ -50,7 +51,8 @@ public function modify( $schema->getClassName(), $propertySchema, ))->withJsonPointer( - $propertySchema->getPointer() . '/' . $this->key . '/' . JsonSchema::encodePointer($pattern), + $propertySchema->getPointer() . '/' . $this->key + . '/' . JsonSchemaUtil::encodePointer($pattern), ), ); continue; @@ -61,9 +63,9 @@ public function modify( $schemaProcessor, $schema, $pattern, - $propertySchema->navigate("$this->key/" . JsonSchema::encodePointer($pattern)), + $propertySchema->navigate("$this->key/" . JsonSchemaUtil::encodePointer($pattern)), ))->withJsonPointer( - $propertySchema->getPointer() . '/' . $this->key . '/' . JsonSchema::encodePointer($pattern), + $propertySchema->getPointer() . '/' . $this->key . '/' . JsonSchemaUtil::encodePointer($pattern), ), ); } diff --git a/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php index 1ab43cd3..1d9ffafc 100644 --- a/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php @@ -17,6 +17,7 @@ use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; class PropertiesValidatorFactory extends AbstractValidatorFactory { @@ -72,7 +73,7 @@ public function modify( ))->withJsonPointer( $propertySchema->getPointer() . '/properties/' - . JsonSchema::encodePointer((string) $propertyName), + . JsonSchemaUtil::encodePointer((string) $propertyName), ), ); continue; @@ -88,12 +89,12 @@ public function modify( ->withPointer( $propertySchema->getPointer() . '/' . $this->key . '/' - . JsonSchema::encodePointer($propertyName) + . JsonSchemaUtil::encodePointer($propertyName) ) ->withJson([]); } else { $nestedPropertySchema = $propertySchema - ->navigate("$this->key/" . JsonSchema::encodePointer($propertyName)) + ->navigate("$this->key/" . JsonSchemaUtil::encodePointer($propertyName)) ->withJson( $dependencies !== null ? $propertyStructure + ['_dependencies' => $dependencies] @@ -113,7 +114,7 @@ public function modify( $this->addDependencyValidator( $nestedProperty, $schema->getJsonSchema()->navigate( - 'dependencies/' . JsonSchema::encodePointer((string) $propertyName), + 'dependencies/' . JsonSchemaUtil::encodePointer((string) $propertyName), ), $schemaProcessor, $schema, diff --git a/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php index b0a79459..33bcc7bd 100644 --- a/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php @@ -63,16 +63,20 @@ public function modify( return; } + $unevaluatedPointer = $propertySchema->getPointer() . '/' . $this->key; + if ($unevaluatedProperties === false) { $schema->addPostCompositionValidator( - new NoUnevaluatedPropertiesValidator($schema, $propertySchema), + (new NoUnevaluatedPropertiesValidator($schema, $propertySchema)) + ->withJsonPointer($unevaluatedPointer), ); return; } $schema->addPostCompositionValidator( - new UnevaluatedPropertiesValidator($schemaProcessor, $schema, $propertySchema), + (new UnevaluatedPropertiesValidator($schemaProcessor, $schema, $propertySchema)) + ->withJsonPointer($unevaluatedPointer), ); } } diff --git a/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php b/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php index ef61cb65..ddb82c43 100644 --- a/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php +++ b/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php @@ -9,6 +9,7 @@ use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator\AbstractPropertyValidator; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; abstract class SimpleBaseValidatorFactory extends SimplePropertyValidatorFactory { @@ -26,7 +27,7 @@ public function modify( if ($validator instanceof AbstractPropertyValidator) { $validator = $validator->withJsonPointer( - $propertySchema->getPointer() . '/' . JsonSchema::encodePointer($this->key), + $propertySchema->getPointer() . '/' . JsonSchemaUtil::encodePointer($this->key), ); } diff --git a/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php b/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php index 7b7098bb..b618c51e 100644 --- a/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php +++ b/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php @@ -11,6 +11,7 @@ use PHPModelGenerator\Model\Validator\AbstractPropertyValidator; use PHPModelGenerator\Model\Validator\PropertyValidatorInterface; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; abstract class SimplePropertyValidatorFactory extends AbstractValidatorFactory { @@ -28,7 +29,7 @@ public function modify( if ($validator instanceof AbstractPropertyValidator) { $validator = $validator->withJsonPointer( - $propertySchema->getPointer() . '/' . JsonSchema::encodePointer($this->key), + $propertySchema->getPointer() . '/' . JsonSchemaUtil::encodePointer($this->key), ); } diff --git a/src/Model/Validator/ForbiddenPatternPropertiesValidator.php b/src/Model/Validator/ForbiddenPatternPropertiesValidator.php index 16e3d25f..8e59f6ba 100644 --- a/src/Model/Validator/ForbiddenPatternPropertiesValidator.php +++ b/src/Model/Validator/ForbiddenPatternPropertiesValidator.php @@ -6,8 +6,8 @@ use PHPModelGenerator\Exception\Object\InvalidPatternPropertiesException; use PHPModelGenerator\Model\Property\Property; -use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; /** * Validator for patternProperties where the pattern schema is `false`. @@ -33,7 +33,7 @@ public function __construct(private readonly string $pattern, string $className, $this->patternPointer = $propertySchema->getPointer() . '/patternProperties/' - . JsonSchema::encodePointer($this->pattern); + . JsonSchemaUtil::encodePointer($this->pattern); } public function getPattern(): string diff --git a/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php b/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php index fed43a1e..7d013e25 100644 --- a/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php @@ -6,7 +6,6 @@ use PHPModelGenerator\Accessor\AdditionalPropertiesAccessor; use PHPModelGenerator\Accessor\ImmutableAdditionalPropertiesAccessor; -use PHPModelGenerator\Attributes\JsonPointer; use PHPModelGenerator\Exception\FileSystemException; use PHPModelGenerator\Exception\Object\MinPropertiesException; use PHPModelGenerator\Exception\Object\RegularPropertyAsAdditionalPropertyException; @@ -21,6 +20,7 @@ use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookResolver; use PHPModelGenerator\SchemaProcessor\PostProcessor\Internal\AdditionalPropertiesPostProcessor; use PHPModelGenerator\SchemaProcessor\PostProcessor\Internal\SerializationPostProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; use PHPModelGenerator\Utils\RenderHelper; use ReflectionClass; @@ -164,7 +164,7 @@ private function addSetAdditionalPropertyMethod( $objectPropertyPointers = array_combine( array_map(static fn(PropertyInterface $property): string => $property->getName(), $nonInternalProperties), array_map( - static fn(PropertyInterface $property): string => self::resolvePrimaryJsonPointer($property), + static fn(PropertyInterface $property): string => JsonSchemaUtil::resolvePrimaryJsonPointer($property), $nonInternalProperties, ), ); @@ -191,31 +191,6 @@ private function addSetAdditionalPropertyMethod( ); } - /** - * Resolve the pointer to report when a property name collides with a regular property. - * - * A property merged from multiple composition branches (e.g. declared in both the root - * properties block and an allOf branch) carries one #[JsonPointer] attribute per defining - * location, synthesized by PropertyAttributeSynthesizer. Reading that attribute data ties - * this pointer to the same single source of truth used for the generated #[JsonPointer] - * attributes, instead of independently recomputing it from - * $property->getJsonSchema()->getPointer() — a second computation that would silently - * diverge from the attribute data if PropertyMerger's choice of JsonSchema ever changes. - * - * RegularPropertyAsAdditionalPropertyException carries a single pointer, so when a property - * has multiple declaration sites only the first (root-preferred) one is reported — knowing - * any one true location is sufficient to explain the name collision. - */ - private static function resolvePrimaryJsonPointer(PropertyInterface $property): string - { - foreach ($property->getAttributes() as $attribute) { - if ($attribute->getFqcn() === JsonPointer::class) { - return (string) $attribute->getArguments()[0]; - } - } - - return $property->getJsonSchema()->getPointer(); - } /** * @throws SchemaException diff --git a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl index 3e7bcab1..94e5a120 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl @@ -8,14 +8,19 @@ private function _setUnevaluatedProperty( ): void { {% if hasObjectProperties %} if (in_array($key, {{ objectProperties }})) { - throw new RegularPropertyAsUnevaluatedPropertyException($value, $key, self::class); + throw new RegularPropertyAsUnevaluatedPropertyException( + $value, + $key, + {{ objectPropertyPointers }}[$key] ?? '', + self::class, + ); } {% endif %} {% if hasPatternProperties %} - foreach ({{ patternProperties }} as $pattern) { - if (preg_match($pattern, $key)) { - throw new RegularPropertyAsUnevaluatedPropertyException($value, $key, self::class); + foreach ({{ patternProperties }} as $regex => $pointer) { + if (preg_match($regex, $key)) { + throw new RegularPropertyAsUnevaluatedPropertyException($value, $key, $pointer, self::class); } } {% endif %} diff --git a/src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php b/src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php index 590b2b66..20bc9d87 100644 --- a/src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php @@ -21,6 +21,7 @@ use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ArrayTypeHintDecorator; use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookResolver; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; use PHPModelGenerator\Utils\RenderHelper; use ReflectionClass; @@ -145,58 +146,83 @@ private function locateUnevaluatedValidator(Schema $schema): ?UnevaluatedPropert * Walks composition validators registered on the schema's base validators and recurses * through nested allOf/anyOf/oneOf/if-then-else by inspecting each branch's raw JSON. * - * @return array{0: string[], 1: string[]} [propertyNames, patternRegexes] + * Each branch's schema contributes its own JSON pointer root; a `properties.foo` declaration + * inside `/allOf/0` therefore reports the pointer `/allOf/0/properties/foo`. When the same + * name (or pattern) appears in multiple branches, the first branch-order encounter wins so + * the emitted map has stable content across regenerations. + * + * @return array{0: array, 1: array} + * [propertyName => pointer, patternRegex => pointer] */ private function harvestCompositionPropertyNames(Schema $schema): array { - $propertyNames = []; - $patternRegexes = []; + $propertyPointers = []; + $patternPointers = []; foreach ($schema->getBaseValidators() as $baseValidator) { if ($baseValidator instanceof AbstractComposedPropertyValidator) { foreach ($baseValidator->getComposedProperties() as $branch) { + $branchSchema = $branch->getBranchSchema(); $this->collectBranchPropertyNames( - $branch->getBranchSchema()->getJson(), - $propertyNames, - $patternRegexes, + $branchSchema->getJson(), + $branchSchema->getPointer(), + $propertyPointers, + $patternPointers, ); } } } - return [array_values(array_unique($propertyNames)), array_values(array_unique($patternRegexes))]; + return [$propertyPointers, $patternPointers]; } /** * Walks a single branch's raw JSON schema and appends its `properties` keys and - * `patternProperties` regexes to the accumulators. Recurses into nested composition - * keywords because a branch may itself contain allOf/anyOf/oneOf whose sub-branches - * declare further properties. + * `patternProperties` regexes to the accumulators, along with the pointer at which each + * declaration lives. Recurses into nested composition keywords because a branch may itself + * contain allOf/anyOf/oneOf whose sub-branches declare further properties. * - * @param string[] $propertyNames accumulator for collected property names - * @param string[] $patternRegexes accumulator for collected pattern regexes + * @param array $propertyPointers accumulator: name => JSON pointer + * @param array $patternPointers accumulator: regex => JSON pointer */ - private function collectBranchPropertyNames(array $branchJson, array &$propertyNames, array &$patternRegexes): void - { + private function collectBranchPropertyNames( + array $branchJson, + string $branchPointer, + array &$propertyPointers, + array &$patternPointers, + ): void { foreach (array_keys($branchJson['properties'] ?? []) as $name) { - $propertyNames[] = (string) $name; + $name = (string) $name; + $propertyPointers[$name] ??= $branchPointer . '/properties/' . JsonSchemaUtil::encodePointer($name); } foreach (array_keys($branchJson['patternProperties'] ?? []) as $pattern) { - $patternRegexes[] = (string) $pattern; + $pattern = (string) $pattern; + $patternPointers[$pattern] ??= + $branchPointer . '/patternProperties/' . JsonSchemaUtil::encodePointer($pattern); } foreach (['allOf', 'anyOf', 'oneOf'] as $compositionKey) { - foreach ($branchJson[$compositionKey] ?? [] as $nestedBranchJson) { + foreach ($branchJson[$compositionKey] ?? [] as $index => $nestedBranchJson) { if (is_array($nestedBranchJson)) { - $this->collectBranchPropertyNames($nestedBranchJson, $propertyNames, $patternRegexes); + $this->collectBranchPropertyNames( + $nestedBranchJson, + $branchPointer . '/' . $compositionKey . '/' . $index, + $propertyPointers, + $patternPointers, + ); } } } foreach (['if', 'then', 'else'] as $conditionalKey) { if (isset($branchJson[$conditionalKey]) && is_array($branchJson[$conditionalKey])) { - $this->collectBranchPropertyNames($branchJson[$conditionalKey], $propertyNames, $patternRegexes); + $this->collectBranchPropertyNames( + $branchJson[$conditionalKey], + $branchPointer . '/' . $conditionalKey, + $propertyPointers, + $patternPointers, + ); } } } @@ -279,28 +305,38 @@ private function addSetUnevaluatedPropertyMethod( GeneratorConfiguration $generatorConfiguration, PropertyInterface $validationProperty, ): void { - $directProperties = array_map( - static fn(PropertyInterface $property): string => $property->getName(), - array_filter( - $schema->getProperties(), - static fn(PropertyInterface $property): bool => !$property->isInternal(), - ), + $nonInternalProperties = array_filter( + $schema->getProperties(), + static fn(PropertyInterface $property): bool => !$property->isInternal(), ); - $directPatterns = array_keys($schema->getJsonSchema()->getJson()['patternProperties'] ?? []); + $directPropertyPointers = []; + foreach ($nonInternalProperties as $property) { + $directPropertyPointers[$property->getName()] = JsonSchemaUtil::resolvePrimaryJsonPointer($property); + } + + $schemaRootPointer = $schema->getJsonSchema()->getPointer(); + $directPatternPointers = []; + foreach (array_keys($schema->getJsonSchema()->getJson()['patternProperties'] ?? []) as $pattern) { + $pattern = (string) $pattern; + $directPatternPointers[$pattern] = + $schemaRootPointer . '/patternProperties/' . JsonSchemaUtil::encodePointer($pattern); + } // A composition branch's `properties` / `patternProperties` declarations contribute keys // to the evaluated set at runtime (when the branch succeeds). Setting such a key via // unevaluatedProperties()->set() would bypass the branch's own type/constraint // validation, so the shim must reject those keys with the same exception used for - // directly-declared properties. - [$compositionProperties, $compositionPatterns] = $this->harvestCompositionPropertyNames($schema); + // directly-declared properties. Root-declared entries win over branch-declared entries + // with the same name/pattern, so the reported pointer for a name that lives at both + // levels points at the root. + [$compositionPropertyPointers, $compositionPatternPointers] = $this->harvestCompositionPropertyNames($schema); - $objectProperties = array_values(array_unique(array_merge($directProperties, $compositionProperties))); - $patternProperties = array_values(array_unique(array_merge($directPatterns, $compositionPatterns))); + $objectPropertyPointers = $directPropertyPointers + $compositionPropertyPointers; + $patternPropertyPointers = $directPatternPointers + $compositionPatternPointers; - $hasObjectProperties = $objectProperties !== []; - $hasPatternProperties = $patternProperties !== []; + $hasObjectProperties = $objectPropertyPointers !== []; + $hasPatternProperties = $patternPropertyPointers !== []; if ($hasObjectProperties || $hasPatternProperties) { $schema->addUsedClass(RegularPropertyAsUnevaluatedPropertyException::class); @@ -315,10 +351,11 @@ private function addSetUnevaluatedPropertyMethod( [ 'validationProperty' => $validationProperty, 'hasObjectProperties' => $hasObjectProperties, - 'objectProperties' => RenderHelper::varExportArray($objectProperties), + 'objectProperties' => RenderHelper::varExportArray(array_keys($objectPropertyPointers)), + 'objectPropertyPointers' => RenderHelper::varExportArray($objectPropertyPointers), 'hasPatternProperties' => $hasPatternProperties, 'patternProperties' => $hasPatternProperties - ? RenderHelper::varExportPcrePatterns($patternProperties) + ? RenderHelper::varExportPcrePatternMap($patternPropertyPointers) : null, 'schemaHookResolver' => new SchemaHookResolver($schema), ], diff --git a/src/Utils/JsonSchema.php b/src/Utils/JsonSchema.php new file mode 100644 index 00000000..a5d9eecf --- /dev/null +++ b/src/Utils/JsonSchema.php @@ -0,0 +1,57 @@ +getJsonSchema()->getPointer(), which would silently diverge if + * PropertyMerger's choice of JsonSchema changed. + */ + public static function resolvePrimaryJsonPointer(PropertyInterface $property): string + { + foreach ($property->getAttributes() as $attribute) { + if ($attribute->getFqcn() === JsonPointer::class) { + return (string) $attribute->getArguments()[0]; + } + } + + return $property->getJsonSchema()->getPointer(); + } +} diff --git a/src/Utils/RenderHelper.php b/src/Utils/RenderHelper.php index 4d3e979a..135956a1 100644 --- a/src/Utils/RenderHelper.php +++ b/src/Utils/RenderHelper.php @@ -204,6 +204,24 @@ public static function varExportPcrePatterns(array $rawPatterns): string ); } + /** + * Returns a PHP array literal keyed by each raw pattern wrapped for direct `preg_match` + * use. Distinct from varExportPcrePatterns because the caller needs the values (not the + * numeric indices) so a per-pattern piece of metadata such as a JSON pointer can be looked + * up at runtime by the matched pattern. + * + * @param array $rawPatternMap Keyed by raw regex; value is preserved verbatim. + */ + public static function varExportPcrePatternMap(array $rawPatternMap): string + { + $keyed = []; + foreach ($rawPatternMap as $rawPattern => $value) { + $keyed['/' . addcslashes((string) $rawPattern, '/') . '/'] = $value; + } + + return var_export($keyed, true); + } + public static function filterClassImports(array $imports, string $namespace): array { // filter out non-compound uses and uses which link to the current namespace diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 014b8149..55b7f841 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -113,6 +113,13 @@ public function testFalseFormRejectsUnevaluatedIndicesWithFullMessage( $exception->getUnevaluatedItems(), 'getUnevaluatedItems() must report the same indices the message lists', ); + // `tags` is the array property at /properties/tags; its unevaluatedItems keyword + // sits at /properties/tags/unevaluatedItems, which is the pointer stamped by the + // factory when the false-form validator is constructed. + $this->assertSame( + '/properties/tags/unevaluatedItems', + $exception->getJsonPointer()->pointer, + ); } } @@ -150,6 +157,10 @@ public function testSchemaFormRejectionPreservesNestedInvalidTypeExceptionForEac MSG, $exception->getMessage(), ); + $this->assertSame( + '/properties/tags/unevaluatedItems', + $exception->getJsonPointer()->pointer, + ); $invalidItems = $exception->getInvalidItems(); $this->assertSame( @@ -304,6 +315,10 @@ public function testAllOfBranchesContributeEvaluatedIndices(): void $exception->getMessage(), ); $this->assertSame([2], $exception->getUnevaluatedItems()); + $this->assertSame( + '/properties/tags/unevaluatedItems', + $exception->getJsonPointer()->pointer, + ); } } diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index 15049a24..007e02b1 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -218,6 +218,9 @@ public function testRequiredAndUnevaluatedBothFireWhenBothAreViolated(): void $exception->getMessage(), ); $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); + // Root-level unevaluatedProperties: false — pointer stamped by the factory identifies + // the exact schema keyword that produced the rejection. + $this->assertSame('/unevaluatedProperties', $exception->getJsonPointer()->pointer); } // Collect-errors mode — both errors must land in the registry. @@ -249,6 +252,7 @@ public function testRequiredAndUnevaluatedBothFireWhenBothAreViolated(): void '/^Provided JSON for \S+ contains not allowed unevaluated properties \[extra\]$/', $unevaluatedErrors[0]->getMessage(), ); + $this->assertSame('/unevaluatedProperties', $unevaluatedErrors[0]->getJsonPointer()->pointer); } } @@ -306,6 +310,7 @@ public function testEmptyAllOfStillEnforcesUnevaluatedAtOuterLevel(): void $exception->getMessage(), ); $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); + $this->assertSame('/unevaluatedProperties', $exception->getJsonPointer()->pointer); } } @@ -437,6 +442,7 @@ public function testUnevaluatedPropertiesReferencedViaRef(): void '/^Provided JSON for \S+ contains invalid unevaluated properties/', $exception->getMessage(), ); + $this->assertSame('/unevaluatedProperties', $exception->getJsonPointer()->pointer); } } diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php index 7dcabb22..3573ec0e 100644 --- a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -124,8 +124,19 @@ public function testSetUnevaluatedPropertyRejectsDeclaredPropertyKey(): void $object = new $className(['name' => 'Alice']); - $this->expectException(RegularPropertyAsUnevaluatedPropertyException::class); - $object->unevaluatedProperties()->set('name', 42); + try { + $object->unevaluatedProperties()->set('name', 42); + $this->fail('Expected RegularPropertyAsUnevaluatedPropertyException'); + } catch (RegularPropertyAsUnevaluatedPropertyException $exception) { + $this->assertSame( + "Couldn't add regular property name as unevaluated property to object {$className}", + $exception->getMessage(), + ); + // `name` is declared directly in `properties`, so the pointer identifies the + // property's declaration site — resolved via the property's #[JsonPointer] + // attribute rather than recomputed from schema paths. + $this->assertSame('/properties/name', $exception->getJsonPointer()->pointer); + } } /** @@ -192,11 +203,22 @@ public function testSetRejectsKeysDeclaredInCompositionBranch(): void $object = new $className(); - $this->expectException(RegularPropertyAsUnevaluatedPropertyException::class); - // `branchOwned` is declared only inside the allOf branch, not on the outer schema. - // The accumulator would normally credit it to the branch — setting it via the - // unevaluated accessor must still throw. - $object->unevaluatedProperties()->set('branchOwned', 42); + try { + // `branchOwned` is declared only inside the allOf branch, not on the outer schema. + // The accumulator would normally credit it to the branch — setting it via the + // unevaluated accessor must still throw. + $object->unevaluatedProperties()->set('branchOwned', 42); + $this->fail('Expected RegularPropertyAsUnevaluatedPropertyException'); + } catch (RegularPropertyAsUnevaluatedPropertyException $exception) { + $this->assertSame( + "Couldn't add regular property branchOwned as unevaluated property to object {$className}", + $exception->getMessage(), + ); + // The composition-branch harvest resolves the pointer to the declaration site + // *inside* the allOf branch (index 0), proving the harvest walks branch schemas + // rather than just recording the property name. + $this->assertSame('/allOf/0/properties/branchOwned', $exception->getJsonPointer()->pointer); + } } /** @@ -502,6 +524,12 @@ public function testAdditionalAccessorSetUnderSuppressedUnevaluatedIsPermitted() * The shim's runtime guard rejects keys matching a local `patternProperties` pattern when * the user tries to route them through `unevaluatedProperties()->set()` — they belong to a * different contract (the pattern's type schema) and must go through the pattern accessor. + * + * The fixture's pattern is `^s/~` — deliberately containing both RFC 6901 reserved + * characters. When the guard emits the offending pointer, `/` must escape to `~1` and + * `~` to `~0`, with `~1` applied first so a raw `/` doesn't collide with the intermediate + * `~0`. The pattern's pointer segment therefore becomes `^s~1~0`, giving the assertion + * proof that both replacement rules and their ordering fire. */ public function testPatternAndUnevaluatedAccessorsExposeNonOverlappingBuckets(): void { @@ -515,24 +543,36 @@ public function testPatternAndUnevaluatedAccessorsExposeNonOverlappingBuckets(): (new GeneratorConfiguration())->setImmutable(false), ); - $object = new $className(['name' => 'Alice', 'x_foo' => 'pattern-value', 'count' => 42]); + $object = new $className(['name' => 'Alice', 's/~1' => 'pattern-value', 'count' => 42]); // pattern-matching key landed in _patternProperties, not in _unevaluatedProperties. // The pattern accessor's `get()` returns the full key→value map for the pattern. $patternAccessor = $object->patternProperties(); - $this->assertSame(['x_foo' => 'pattern-value'], $patternAccessor->get('^x_')); + $this->assertSame(['s/~1' => 'pattern-value'], $patternAccessor->get('^s/~')); // non-matching key landed in _unevaluatedProperties only. $unevaluatedAccessor = $object->unevaluatedProperties(); $this->assertSame(['count' => 42], $unevaluatedAccessor->getAll()); - $this->assertNull($unevaluatedAccessor->get('x_foo')); + $this->assertNull($unevaluatedAccessor->get('s/~1')); // Attempting to route a pattern-matching key through the unevaluated accessor is rejected // by the shim guard. - $this->expectException(RegularPropertyAsUnevaluatedPropertyException::class); - $unevaluatedAccessor->set('x_bar', 99); + try { + $unevaluatedAccessor->set('s/~2', 99); + $this->fail('Expected RegularPropertyAsUnevaluatedPropertyException'); + } catch (RegularPropertyAsUnevaluatedPropertyException $exception) { + $this->assertSame( + "Couldn't add regular property s/~2 as unevaluated property to object {$className}", + $exception->getMessage(), + ); + $this->assertSame( + '/patternProperties/^s~1~0', + $exception->getJsonPointer()->pointer, + ); + } } + /** * `remove()` walks both the backing _unevaluatedProperties array and the raw model data so * a subsequent `getAll()` reports the post-removal state and `meta()->rawInput()` no longer diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternAndUnevaluatedCoexist.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternAndUnevaluatedCoexist.json index eda63e50..3f4fdbd2 100644 --- a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternAndUnevaluatedCoexist.json +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternAndUnevaluatedCoexist.json @@ -6,7 +6,7 @@ } }, "patternProperties": { - "^x_": { + "^s/~": { "type": "string" } }, From a7f941815ffb66b3eadf9f2807357e7723c3a8ce Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 7 Jul 2026 11:31:08 +0200 Subject: [PATCH 16/51] Warn and skip on unevaluatedProperties dead cells + edge-case tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UnevaluatedPropertiesValidatorFactory now consolidates all four sibling shapes that leave the unevaluatedProperties bucket permanently empty into a single deadCodeReason() helper: - additionalProperties: true — every extra flows to the model but the accumulator does not credit it, so the validator would still fire and defeat the intent of `additionalProperties: true`. - additionalProperties: {schema} — every extra is claimed and validated by additionalProperties; the unevaluated set is empty. - additionalProperties: false — every extra is rejected before the post-composition phase, so the unevaluated validator never runs. - denyAdditionalProperties() generator flag with additionalProperties absent — synthesises the same false shape at configuration time. Each cell emits a distinct warning via the existing echo channel so build-output greps can identify the specific dead shape, and skips validator emission entirely. Tests added: - Object-side dead-code data provider covering all four shapes plus the denyAdditionalProperties() variant. Rows pass the GeneratorConfiguration directly (setOutputEnabled(true) on the base, chained with setDenyAdditionalProperties(true) on the deny row) so the test signature drops the closure/fallback boilerplate. - testAdditionalFalseRejectsExtrasEvenWhenUnevaluatedSchemaWouldAccept proves the factory suppressed the validator: an extra that would satisfy the unevaluated integer schema is still rejected because additionalProperties: false runs first. - testContradictoryInnerSchemaThrowsSchemaExceptionPointingAtFile pins that an unevaluatedProperties schema with contradictory allOf types surfaces via the existing "conflicting types" SchemaException path, with the file identifier preserved. - testPropertyNamesRejectionPrecedesUnevaluated data-provider variant covering both direct-exception and error-collection modes. The registry message pin includes the base-phase propertyNames block followed by the post-composition InvalidUnevaluatedPropertiesException block; template uses `{className}` + str_replace for interpolation. - testDependentSchemasContributionCreditsDependentPropertiesToAccumulator is skipped (dependentSchemas applicator not yet implemented) but asserts both the intended success path (kind + covered `extra`) and the failure path (kind + non-covered `stray`) so removing the skip line makes the applicator's contract live. - Array-side testContainsWithMinContainsZeroCreditsMatchedIndicesOnly and testOneOfBranchesOfDifferentTupleLengthsControlEvaluatedSet cover contains + minContains: 0 and mixed-tuple-length composition. Object-side dead-code fixtures cover all six sibling shapes; denyAdditional variant paired with the config row. Co-Authored-By: Claude Opus 4.7 --- .../UnevaluatedPropertiesValidatorFactory.php | 55 ++++- tests/Basic/UnevaluatedItemsValidatorTest.php | 73 ++++++ .../UnevaluatedPropertiesValidatorTest.php | 227 ++++++++++++++++++ ...tedPropertiesAccessorPostProcessorTest.php | 4 +- .../ContainsMinContainsZeroBranch.json | 17 ++ .../OneOfDifferentTupleLengths.json | 34 +++ .../AdditionalFalseDeadCode.json | 10 + .../AdditionalFalseWithUnevaluatedSchema.json | 12 + .../AdditionalSchemaDeadCode.json | 12 + .../AdditionalTrueDeadCode.json | 10 + .../ContradictoryUnevaluatedSchema.json | 18 ++ .../DenyAdditionalDeadCode.json | 9 + .../DependentSchemasWithUnevaluated.json | 18 ++ .../PropertyNamesFailsFirst.json | 9 + 14 files changed, 497 insertions(+), 11 deletions(-) create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/ContainsMinContainsZeroBranch.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/OneOfDifferentTupleLengths.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseDeadCode.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseWithUnevaluatedSchema.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalSchemaDeadCode.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalTrueDeadCode.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/ContradictoryUnevaluatedSchema.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/DenyAdditionalDeadCode.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/DependentSchemasWithUnevaluated.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/PropertyNamesFailsFirst.json diff --git a/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php index 33bcc7bd..30a29f16 100644 --- a/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php @@ -45,18 +45,24 @@ public function modify( ); } - // When the same schema declares a non-false `additionalProperties`, every key not - // claimed by `properties`/`patternProperties` is already evaluated by it. Composition - // contributions cannot retract that — `additionalProperties` only succeeds when every - // matched key passes — so the unevaluated set is guaranteed empty and the validator - // would be a no-op. Skip emission entirely and warn the developer that the keyword - // is dead code at this schema level. - if (isset($json['additionalProperties']) && $json['additionalProperties'] !== false) { + // A sibling `additionalProperties` (or the effective `false` produced by the + // `denyAdditionalProperties()` generator flag) short-circuits the unevaluated bucket: + // - `true` — every extra flows to the model unchecked, but the accumulator does + // not credit those keys, so unevaluatedProperties would still fire. + // Emitting it defeats the intent of `additionalProperties: true` + // (accept every extra), so we suppress and warn. + // - `{schema}` — every extra is claimed by additionalProperties, leaving the + // unevaluated set permanently empty. + // - `false` — every extra is rejected by additionalProperties before the + // post-composition phase, so the unevaluated validator never sees + // any keys. + $deadCodeReason = $this->deadCodeReason($schemaProcessor, $json); + if ($deadCodeReason !== null) { if ($schemaProcessor->getGeneratorConfiguration()->isOutputEnabled()) { echo sprintf( - "Warning: unevaluatedProperties on %s is dead code — sibling additionalProperties" - . " already claims every extra key\n", + "Warning: unevaluatedProperties on %s is dead code — %s\n", $schema->getClassName(), + $deadCodeReason, ); } @@ -79,4 +85,35 @@ public function modify( ->withJsonPointer($unevaluatedPointer), ); } + + /** + * Returns the human-readable reason unevaluatedProperties is dead code, or null when the + * validator must still be emitted. Consolidates the four sibling shapes that leave the + * unevaluated bucket permanently empty at this schema level. + */ + private function deadCodeReason(SchemaProcessor $schemaProcessor, array $json): ?string + { + if (array_key_exists('additionalProperties', $json)) { + $additionalProperties = $json['additionalProperties']; + + if ($additionalProperties === true) { + return 'sibling additionalProperties: true accepts every extra without crediting' + . ' the unevaluated accumulator'; + } + + if ($additionalProperties === false) { + return 'sibling additionalProperties: false rejects every extra before the' + . ' unevaluated phase runs'; + } + + return 'sibling additionalProperties: {schema} already validates every extra key'; + } + + if ($schemaProcessor->getGeneratorConfiguration()->denyAdditionalProperties()) { + return 'denyAdditionalProperties() flips missing additionalProperties to false,' + . ' rejecting every extra before the unevaluated phase runs'; + } + + return null; + } } diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 55b7f841..7acd837b 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -198,6 +198,7 @@ public function testSchemaFormRejectionPreservesNestedInvalidTypeExceptionForEac public function testUniqueItemsExceptionIdentityIsPreserved(): void { $this->expectException(UniqueItemsException::class); + $this->expectExceptionMessage('Items of array tags are not unique'); $className = $this->generateClassFromFile('UnevaluatedFalseWithUniqueItems.json'); new $className(['tags' => ['dup', 'dup']]); @@ -579,4 +580,76 @@ public function testInnerUnevaluatedItemsFailureRollsBackPartialWritesUnderColle ); } } + + /** + * `contains` with `minContains: 0` credits every index whose value matched the contains + * schema — even when zero matches would still satisfy the branch. The evaluated set is + * driven by which indices matched, not by the count. Non-matching indices remain + * unevaluated and the outer `unevaluatedItems: false` rejects them. + * + * Two assertions on the same generated class: + * - `[]` accepts because no indices exist to be evaluated; contains-empty is legal + * under minContains: 0 and the branch succeeds. + * - `[1, 'a', 2, 'b', 3]` — indices 0, 2, 4 match the integer contains schema; indices + * 1, 3 are non-matching and surface as unevaluated. + */ + public function testContainsWithMinContainsZeroCreditsMatchedIndicesOnly(): void + { + $className = $this->generateClassFromFile('ContainsMinContainsZeroBranch.json'); + + // Empty array: contains matches zero indices but minContains: 0 keeps the branch + // successful. Nothing is unevaluated because there are no indices to evaluate. + $accepted = new $className(['tags' => []]); + $this->assertSame([], $accepted->getTags()); + + // Mixed array: only integer indices are credited; string indices fall through to + // unevaluatedItems: false and are reported in declaration order. + try { + new $className(['tags' => [1, 'a', 2, 'b', 3]]); + $this->fail('Expected UnevaluatedItemsException for the two non-matching indices'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + 'Provided JSON for tags contains not allowed unevaluated items [#1, #3]', + $exception->getMessage(), + ); + $this->assertSame([1, 3], $exception->getUnevaluatedItems()); + } + } + + /** + * `oneOf` with two branches of different tuple lengths: the branch that succeeds + * determines how many indices are evaluated. Only one branch may succeed at a time + * because otherwise `oneOf` fails as a whole. The outer `unevaluatedItems: false` then + * inspects the surviving branch's evaluated indices and rejects any tail past that + * length. + * + * Three assertions on the same generated class: + * - `['a', 'b']` matches only branch 0 (strings, tuple length 2); no tail indices, + * accepted. + * - `[1, 2, 3]` matches only branch 1 (integers, tuple length 3); no tail indices, + * accepted. + * - `[1, 2, 3, 4]` — branch 1 succeeds and covers indices 0-2, but index 3 is not + * covered by any successful branch. `unevaluatedItems: false` rejects. + */ + public function testOneOfBranchesOfDifferentTupleLengthsControlEvaluatedSet(): void + { + $className = $this->generateClassFromFile('OneOfDifferentTupleLengths.json'); + + $acceptedStrings = new $className(['tags' => ['a', 'b']]); + $this->assertSame(['a', 'b'], $acceptedStrings->getTags()); + + $acceptedIntegers = new $className(['tags' => [1, 2, 3]]); + $this->assertSame([1, 2, 3], $acceptedIntegers->getTags()); + + try { + new $className(['tags' => [1, 2, 3, 4]]); + $this->fail('Expected UnevaluatedItemsException for index 3 past widest surviving tuple'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + 'Provided JSON for tags contains not allowed unevaluated items [#3]', + $exception->getMessage(), + ); + $this->assertSame([3], $exception->getUnevaluatedItems()); + } + } } diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index 007e02b1..8853fd6e 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -5,10 +5,13 @@ namespace PHPModelGenerator\Tests\Basic; use PHPModelGenerator\Exception\ErrorRegistryException; +use PHPModelGenerator\Exception\Object\AdditionalPropertiesException; +use PHPModelGenerator\Exception\Object\InvalidPropertyNamesException; use PHPModelGenerator\Exception\Object\InvalidUnevaluatedPropertiesException; use PHPModelGenerator\Exception\Object\NestedObjectException; use PHPModelGenerator\Exception\Object\RequiredValueException; use PHPModelGenerator\Exception\Object\UnevaluatedPropertiesException; +use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use PHPModelGenerator\Tests\Support\ApplicableDrafts; @@ -611,4 +614,228 @@ public function testExternalRefBranchTreatsAllKeysAsEvaluated(): void . '(same pre-existing bug that blocks the array-side self-referencing test).', ); } + + /** + * Sibling `additionalProperties` (or the effective `false` produced by the + * `denyAdditionalProperties()` config flag) short-circuits the unevaluated bucket. Each row + * pins the exact factory warning so a message drift in the factory surfaces here rather than + * as a silent behaviour change to consumers who grep build output for these hints. + * + * @return array + */ + public static function deadCodeProvider(): array + { + $baseConfig = static fn(): GeneratorConfiguration => + (new GeneratorConfiguration())->setOutputEnabled(true); + + return [ + // additionalProperties: true accepts every extra unchecked; without a matching + // annotation contribution the unevaluated accumulator would still reject them, + // which would defeat the intent of `additionalProperties: true`. + 'additionalProperties: true suppresses unevaluated' => [ + 'AdditionalTrueDeadCode.json', + 'sibling additionalProperties: true accepts every extra without crediting the unevaluated accumulator', + $baseConfig(), + ], + // additionalProperties: {schema} validates and claims every extra; unevaluated has + // nothing left to reach. + 'additionalProperties: {schema} claims every extra' => [ + 'AdditionalSchemaDeadCode.json', + 'sibling additionalProperties: {schema} already validates every extra key', + $baseConfig(), + ], + // additionalProperties: false rejects every extra at the base-validator phase, + // long before the post-composition unevaluated validator would run. + 'additionalProperties: false rejects every extra first' => [ + 'AdditionalFalseDeadCode.json', + 'sibling additionalProperties: false rejects every extra before the unevaluated phase runs', + $baseConfig(), + ], + // Same as the previous row but with the schema form of unevaluatedProperties. + 'additionalProperties: false leaves unevaluated {schema} unreachable' => [ + 'AdditionalFalseWithUnevaluatedSchema.json', + 'sibling additionalProperties: false rejects every extra before the unevaluated phase runs', + $baseConfig(), + ], + // denyAdditionalProperties() flips a missing additionalProperties to false at + // configuration time — the same dead-cell shape as the explicit false row but + // reached through the generator config instead of the JSON schema. + 'denyAdditionalProperties() flag mimics false and warns' => [ + 'DenyAdditionalDeadCode.json', + 'denyAdditionalProperties() flips missing additionalProperties to false,' + . ' rejecting every extra before the unevaluated phase runs', + $baseConfig()->setDenyAdditionalProperties(true), + ], + ]; + } + + /** + * Each dead-cell shape emits the factory warning under the `echo` channel and skips + * emitting the unevaluated validator. Where an assertion on the resulting class is + * meaningful (extras still land where their governing keyword expects them), the test + * exercises the runtime path after checking the warning text. + */ + #[DataProvider('deadCodeProvider')] + public function testDeadCellShapesEmitWarningAndSkipValidator( + string $schemaFile, + string $reasonFragment, + GeneratorConfiguration $config, + ): void { + $this->expectOutputRegex( + '/Warning: unevaluatedProperties on \S+ is dead code — ' . preg_quote($reasonFragment, '/') . '/', + ); + + $className = $this->generateClassFromFile($schemaFile, $config); + + // Constructing with just the declared property must always succeed — the suppressed + // unevaluated validator can never contribute a false negative here. + $instance = new $className(['name' => 'Alice']); + $this->assertSame(['name' => 'Alice'], $instance->meta()->rawInput()); + } + + /** + * `additionalProperties: false` combined with `unevaluatedProperties: {schema}` (or `false`) + * must reject extras with `AdditionalPropertiesException`, never `UnevaluatedPropertiesException`, + * because the unevaluated validator is not emitted. Extra property `count: 42` would satisfy + * the unevaluated integer schema, so if the unevaluated validator were still running the + * construction would succeed. The rejection therefore proves the factory suppressed the + * validator as intended. + */ + public function testAdditionalFalseRejectsExtrasEvenWhenUnevaluatedSchemaWouldAccept(): void + { + $className = $this->generateClassFromFile('AdditionalFalseWithUnevaluatedSchema.json'); + + $this->expectException(AdditionalPropertiesException::class); + $this->expectExceptionMessage( + "Provided JSON for {$className} contains not allowed additional properties [count]", + ); + + new $className(['name' => 'Alice', 'count' => 42]); + } + + /** + * `unevaluatedProperties: {schema}` whose inner schema is contradictory (allOf of two + * incompatible non-null types) must surface the same `SchemaException` the rest of the + * codebase throws for contradictory allOf types. The contradictory-type detection is + * shared machinery; this test only pins that the SchemaException identity survives when + * the offending subschema is nested under `unevaluatedProperties`. + */ + public function testContradictoryInnerSchemaThrowsSchemaExceptionPointingAtFile(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + "/^Property 'unevaluated property' is defined with conflicting types in allOf" + . ' composition branches \\(file \\S+\\)\\. allOf requires all constraints to' + . ' hold simultaneously, making this schema unsatisfiable\\.$/', + ); + + $this->generateClassFromFile('ContradictoryUnevaluatedSchema.json'); + } + + /** + * `propertyNames` and `unevaluatedProperties` are orthogonal but propertyNames runs first + * because it is a base-phase validator whereas unevaluatedProperties is a post-composition + * validator. A key that violates the propertyNames regex must surface + * `InvalidPropertyNamesException` in direct-exception mode — even if the offending key's + * value would also fail the unevaluated schema, unevaluated never runs. Under error + * collection both fires do land in the registry because the base phase does not abort; + * assert both classes are present and the combined message pins the ordering. + * + * @return array + * [config, expected exception class, expected message with %s placeholder for class name] + */ + public static function propertyNamesRejectionProvider(): array + { + return [ + // Direct-exception mode: propertyNames throws first and the constructor never + // reaches the post-composition phase where unevaluated lives. The + // InvalidPropertyNamesException wraps the inner PatternException. The inner one + // names 'property name' (the sub-property name the propertyNames validator + // assigns) rather than the offending key; the outer names the offending key + // ('FOO') on its enclosing line. + 'direct exception surfaces propertyNames only' => [ + (new GeneratorConfiguration())->setCollectErrors(false), + InvalidPropertyNamesException::class, + <<<'MSG' + Provided JSON for {className} contains properties with invalid names. + - invalid property 'FOO' + * Value for property name doesn't match pattern ^[a-z]+$ + MSG, + ], + // Error-collection mode: ErrorRegistryException joins each collected error with a + // single "\n" (no blank line). The propertyNames block lands first (base phase); + // the unevaluated-schema block lands second (post-composition phase). The value + // 'bar' fails the unevaluated schema's `type: integer` check, so the inner + // InvalidUnevaluatedPropertiesException reports the type mismatch — not the + // false-form "not allowed unevaluated properties" phrasing. + 'collected errors capture both failures in phase order' => [ + (new GeneratorConfiguration())->setCollectErrors(true), + ErrorRegistryException::class, + <<<'MSG' + Provided JSON for {className} contains properties with invalid names. + - invalid property 'FOO' + * Value for property name doesn't match pattern ^[a-z]+$ + Provided JSON for {className} contains invalid unevaluated properties. + - invalid unevaluated property 'FOO' + * Invalid type for unevaluated property. Requires int, got string + MSG, + ], + ]; + } + + #[DataProvider('propertyNamesRejectionProvider')] + public function testPropertyNamesRejectionPrecedesUnevaluated( + GeneratorConfiguration $config, + string $expectedExceptionClass, + string $expectedMessageTemplate, + ): void { + $className = $this->generateClassFromFile('PropertyNamesFailsFirst.json', $config); + + $this->expectException($expectedExceptionClass); + $this->expectExceptionMessage(str_replace('{className}', $className, $expectedMessageTemplate)); + + new $className(['FOO' => 'bar']); + } + + /** + * `dependentSchemas` is a Draft 2019-09 applicator whose dependent subschemas contribute + * annotations to the unevaluated accumulator: when the trigger key is present on the + * instance and the dependent subschema validates, its `properties`/`patternProperties`/ + * `additionalProperties` claims flow into the enclosing accumulator. The fixture + * declares `dependentSchemas: {kind: {properties: {extra: {type: integer}}}}` alongside + * `unevaluatedProperties: false`. + * + * Two shapes exercise the applicator once implemented: + * 1. `{kind, extra}` — `extra` is covered by the dependent subschema's `properties` + * declaration, so the accumulator credits it and construction succeeds. + * 2. `{kind, stray}` — `stray` is NOT covered by the dependent subschema, so the + * accumulator does not credit it and `unevaluatedProperties: false` rejects. + * + * Assertions are written for the post-implementation shape; the test is currently + * skipped because dependentSchemas is not yet recognised by the schema processor. When + * the applicator lands, removing the markTestSkipped line makes both assertions live. + */ + public function testDependentSchemasContributionCreditsDependentPropertiesToAccumulator(): void + { + $this->markTestSkipped( + 'dependentSchemas applicator not yet implemented in the schema processor; the' + . ' fixture and assertions describe the intended post-implementation shape.', + ); + + // @phpstan-ignore-next-line dead code — unreachable until the skip is removed + $className = $this->generateClassFromFile('DependentSchemasWithUnevaluated.json'); + + // Success path: `extra` is covered by the dependent subschema — accepted. + $instance = new $className(['kind' => 'X', 'extra' => 5]); + $this->assertSame(['kind' => 'X', 'extra' => 5], $instance->meta()->rawInput()); + + // Failure path: `stray` is not covered anywhere — `unevaluatedProperties: false` + // rejects. The pin proves that dependentSchemas only credits what its own subschema + // actually declares. + $this->expectException(UnevaluatedPropertiesException::class); + $this->expectExceptionMessage( + "Provided JSON for {$className} contains not allowed unevaluated properties [stray]", + ); + new $className(['kind' => 'X', 'stray' => 5]); + } } diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php index 3573ec0e..93b049d8 100644 --- a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -494,8 +494,8 @@ public function testAdditionalAccessorSetUnderSuppressedUnevaluatedIsPermitted() }; $this->expectOutputRegex( - '/Warning: unevaluatedProperties on \S+ is dead code — sibling additionalProperties ' - . 'already claims every extra key/', + '/Warning: unevaluatedProperties on \S+ is dead code — sibling additionalProperties: ' + . 'true accepts every extra without crediting the unevaluated accumulator/', ); $className = $this->generateClassFromFile( diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ContainsMinContainsZeroBranch.json b/tests/Schema/UnevaluatedItemsValidatorTest/ContainsMinContainsZeroBranch.json new file mode 100644 index 00000000..081ef75c --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ContainsMinContainsZeroBranch.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "oneOf": [ + { + "contains": { + "type": "integer" + }, + "minContains": 0 + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/OneOfDifferentTupleLengths.json b/tests/Schema/UnevaluatedItemsValidatorTest/OneOfDifferentTupleLengths.json new file mode 100644 index 00000000..5f9914d6 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/OneOfDifferentTupleLengths.json @@ -0,0 +1,34 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "oneOf": [ + { + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + { + "items": [ + { + "type": "integer" + }, + { + "type": "integer" + }, + { + "type": "integer" + } + ] + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseDeadCode.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseDeadCode.json new file mode 100644 index 00000000..e1576f34 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseDeadCode.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseWithUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseWithUnevaluatedSchema.json new file mode 100644 index 00000000..31817051 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseWithUnevaluatedSchema.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalSchemaDeadCode.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalSchemaDeadCode.json new file mode 100644 index 00000000..c46ad6c3 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalSchemaDeadCode.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalTrueDeadCode.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalTrueDeadCode.json new file mode 100644 index 00000000..ccefbd8d --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalTrueDeadCode.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": true, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/ContradictoryUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/ContradictoryUnevaluatedSchema.json new file mode 100644 index 00000000..2526eb06 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/ContradictoryUnevaluatedSchema.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "allOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/DenyAdditionalDeadCode.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/DenyAdditionalDeadCode.json new file mode 100644 index 00000000..59b98669 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/DenyAdditionalDeadCode.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/DependentSchemasWithUnevaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/DependentSchemasWithUnevaluated.json new file mode 100644 index 00000000..7873e0d1 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/DependentSchemasWithUnevaluated.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "dependentSchemas": { + "kind": { + "properties": { + "extra": { + "type": "integer" + } + } + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/PropertyNamesFailsFirst.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/PropertyNamesFailsFirst.json new file mode 100644 index 00000000..a593fed1 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/PropertyNamesFailsFirst.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "propertyNames": { + "pattern": "^[a-z]+$" + }, + "unevaluatedProperties": { + "type": "integer" + } +} From 0b0d49492fc81aaa22f1bb12a86a2841d5d83a71 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 7 Jul 2026 18:31:47 +0200 Subject: [PATCH 17/51] Pin exact class names in unevaluated-property assertions Adds resolveNestedClassName() to the abstract test case so generated class names can be discovered and interpolated into expected exception messages instead of matched via \S+/\w+ regex. Replaces the regex-based assertions across four test files with literal-string assertions, tightening what "the message must contain" checks were previously letting through. Also adds two confirmatory fixtures and tests for patternProperties / additionalProperties value-subschemas that declare their own unevaluatedProperties: false, verifying that the inner class self-activates through its own post-processing pass without needing the parent's activation walk to recurse into these sibling schemas. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 2 +- tests/AbstractPHPModelGeneratorTestCase.php | 35 ++++++ tests/Basic/SchemaDependencyTest.php | 51 +++++--- .../UnevaluatedPropertiesMutabilityTest.php | 24 ++-- .../UnevaluatedPropertiesValidatorTest.php | 119 ++++++++++++++---- ...tedPropertiesAccessorPostProcessorTest.php | 8 +- .../AdditionalValueSubschemaUnevaluated.json | 17 +++ .../PatternValueSubschemaUnevaluated.json | 14 +++ 8 files changed, 207 insertions(+), 63 deletions(-) create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalValueSubschemaUnevaluated.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/PatternValueSubschemaUnevaluated.json diff --git a/CLAUDE.md b/CLAUDE.md index e7b4d4c5..d5499a02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ output is available for analysis without re-running. Use `--display-warnings` to 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 +php -d memory_limit=256M ./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` diff --git a/tests/AbstractPHPModelGeneratorTestCase.php b/tests/AbstractPHPModelGeneratorTestCase.php index 9d3193fb..bb3d835d 100644 --- a/tests/AbstractPHPModelGeneratorTestCase.php +++ b/tests/AbstractPHPModelGeneratorTestCase.php @@ -559,6 +559,41 @@ protected function getGeneratedFiles(): array return $this->generatedFiles; } + /** + * Resolves the names of classes the generator emitted alongside a parent class — i.e. + * files whose basename starts with the parent class name followed by an underscore. This + * is the naming pattern the generator applies to nested schemas produced by properties, + * composition branches, and `patternProperties` / `additionalProperties` value-subschemas. + * The expected count is asserted so a change in the generator's naming scheme surfaces as + * a clear failure rather than a downstream regex mismatch. + * + * Returns a plain string when exactly one nested class is expected (the ergonomic common + * case for exception-message assertions); returns a list of names otherwise. + * + * @return string|string[] + */ + protected function resolveNestedClassName(string $parentClassName, int $expectedCount = 1): string | array + { + $prefix = $parentClassName . '_'; + $nestedFiles = array_values(array_filter( + $this->generatedFiles, + static fn(string $path): bool => str_starts_with(basename($path), $prefix), + )); + + $this->assertCount( + $expectedCount, + $nestedFiles, + sprintf('expected %d nested class file(s) for %s', $expectedCount, $parentClassName), + ); + + $classNames = array_map( + static fn(string $path): string => str_replace('.php', '', basename($path)), + $nestedFiles, + ); + + return $expectedCount === 1 ? $classNames[0] : $classNames; + } + protected function getSchemaFilePath(string $file): string { return __DIR__ . '/Schema/' . $this->getStaticClassName() . '/' . $file; diff --git a/tests/Basic/SchemaDependencyTest.php b/tests/Basic/SchemaDependencyTest.php index ca14635d..8fda7140 100644 --- a/tests/Basic/SchemaDependencyTest.php +++ b/tests/Basic/SchemaDependencyTest.php @@ -202,63 +202,74 @@ public static function validSchemaDependencyCompositionDataProvider(): array #[DataProvider('invalidSchemaDependencyCompositionDataProvider')] public function testInvalidSchemaDependencyComposition( array $propertyValue, - string $message, + string $messageTemplate, ): void { - $this->expectException(ErrorRegistryException::class); - $this->expectExceptionMessageMatches("/$message/m"); - $className = $this->generateClassFromFile( 'CompositionSchemaDependency.json', (new GeneratorConfiguration())->setCollectErrors(true), ); + // Generation emits three nested classes: the dependency wrapper (named + // *_credit_card_Dependency) plus one class per allOf branch. Only the wrapper + // appears in the composition-decline error, so filter for it explicitly. + $nestedClasses = $this->resolveNestedClassName($className, 3); + $wrapperMatches = array_values(array_filter( + $nestedClasses, + static fn(string $name): bool => str_ends_with($name, '_credit_card_Dependency'), + )); + $this->assertCount(1, $wrapperMatches, 'expected exactly one dependency wrapper class'); + $dependencyClass = $wrapperMatches[0]; + + $this->expectException(ErrorRegistryException::class); + $this->expectExceptionMessage( + str_replace('{dependencyClass}', $dependencyClass, $messageTemplate), + ); + new $className($propertyValue); } - // phpcs:disable Generic.Files.LineLength.TooLong public static function invalidSchemaDependencyCompositionDataProvider(): array { return [ 'required attribute not provided 1' => [ ['credit_card' => 12345], - << [ ['credit_card' => 12345, 'age' => 42], - << [ + 'invalid data type' => [ ['credit_card' => 12345, 'name' => false, 'age' => 42], - <<setKind('b'); $this->fail('Expected setKind to throw because alphaOnly becomes unevaluated'); } catch (UnevaluatedPropertiesException $exception) { - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[alphaOnly\]$/', + $this->assertSame( + "Provided JSON for {$className} contains not allowed unevaluated properties [alphaOnly]", $exception->getMessage(), ); $this->assertSame(['alphaOnly'], $exception->getUnevaluatedProperties()); @@ -92,8 +92,8 @@ public function testIfThenElseFlipRollsBackAndSameValueIsNoOp(): void $object->setMode('off'); $this->fail('Expected setMode to throw because onlyWhenOn becomes unevaluated'); } catch (UnevaluatedPropertiesException $exception) { - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[onlyWhenOn\]$/', + $this->assertSame( + "Provided JSON for {$className} contains not allowed unevaluated properties [onlyWhenOn]", $exception->getMessage(), ); $this->assertSame(['onlyWhenOn'], $exception->getUnevaluatedProperties()); @@ -136,8 +136,8 @@ public function testAnyOfSoleCovererFlipMakesOrphanedKeyUnevaluated(): void $object->setKind('y'); $this->fail('Expected setKind to throw because xOnly loses its sole coverer'); } catch (UnevaluatedPropertiesException $exception) { - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[xOnly\]$/', + $this->assertSame( + "Provided JSON for {$className} contains not allowed unevaluated properties [xOnly]", $exception->getMessage(), ); $this->assertSame(['xOnly'], $exception->getUnevaluatedProperties()); @@ -165,8 +165,8 @@ public function testNestedBranchSchemaRejectsExtrasAndRevalidatesThroughNestedIn new $className(['name' => 'Alice', 'foo' => 'hello', 'bar' => 42, 'stray' => 'unclaimed']); $this->fail('constructor must reject the stray key claimed by no branch'); } catch (UnevaluatedPropertiesException $exception) { - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[stray\]$/', + $this->assertSame( + "Provided JSON for {$className} contains not allowed unevaluated properties [stray]", $exception->getMessage(), ); $this->assertSame(['stray'], $exception->getUnevaluatedProperties()); @@ -201,8 +201,8 @@ public function testPopulateRollsBackEveryFieldWhenDeltaBreaksUnevaluated(): voi $object->populate(['kind' => 'b']); $this->fail('Expected populate to throw because alphaOnly becomes unevaluated'); } catch (UnevaluatedPropertiesException $exception) { - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[alphaOnly\]$/', + $this->assertSame( + "Provided JSON for {$className} contains not allowed unevaluated properties [alphaOnly]", $exception->getMessage(), ); $this->assertSame(['alphaOnly'], $exception->getUnevaluatedProperties()); @@ -269,8 +269,8 @@ public function testCollectErrorsModeCollectsValidatorAndUnevaluatedErrorsTogeth $this->assertSame('Value for kind must not be shorter than 3', $minLengthErrors[0]->getMessage()); $this->assertCount(1, $unevaluatedErrors, 'expected one UnevaluatedPropertiesException'); - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[alphaOnly\]$/', + $this->assertSame( + "Provided JSON for {$className} contains not allowed unevaluated properties [alphaOnly]", $unevaluatedErrors[0]->getMessage(), ); $this->assertSame(['alphaOnly'], $unevaluatedErrors[0]->getUnevaluatedProperties()); diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index 8853fd6e..75ce8e53 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -6,6 +6,8 @@ use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\Object\AdditionalPropertiesException; +use PHPModelGenerator\Exception\Object\InvalidAdditionalPropertiesException; +use PHPModelGenerator\Exception\Object\InvalidPatternPropertiesException; use PHPModelGenerator\Exception\Object\InvalidPropertyNamesException; use PHPModelGenerator\Exception\Object\InvalidUnevaluatedPropertiesException; use PHPModelGenerator\Exception\Object\NestedObjectException; @@ -216,8 +218,8 @@ public function testRequiredAndUnevaluatedBothFireWhenBothAreViolated(): void new $directClassName(['extra' => 'value']); $this->fail('Expected the unevaluated check to surface in direct-exception mode'); } catch (UnevaluatedPropertiesException $exception) { - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[extra\]$/', + $this->assertSame( + "Provided JSON for {$directClassName} contains not allowed unevaluated properties [extra]", $exception->getMessage(), ); $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); @@ -251,8 +253,8 @@ public function testRequiredAndUnevaluatedBothFireWhenBothAreViolated(): void $this->assertSame('Missing required value for name', $requiredErrors[0]->getMessage()); $this->assertCount(1, $unevaluatedErrors, 'expected one UnevaluatedPropertiesException'); - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[extra\]$/', + $this->assertSame( + "Provided JSON for {$collectClassName} contains not allowed unevaluated properties [extra]", $unevaluatedErrors[0]->getMessage(), ); $this->assertSame('/unevaluatedProperties', $unevaluatedErrors[0]->getJsonPointer()->pointer); @@ -308,8 +310,8 @@ public function testEmptyAllOfStillEnforcesUnevaluatedAtOuterLevel(): void new $className(['name' => 'Alice', 'extra' => 'value']); $this->fail('Empty allOf must not rescue extras from unevaluatedProperties: false'); } catch (UnevaluatedPropertiesException $exception) { - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[extra\]$/', + $this->assertSame( + "Provided JSON for {$className} contains not allowed unevaluated properties [extra]", $exception->getMessage(), ); $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); @@ -345,14 +347,9 @@ public function testNestedUnevaluatedInBranchPropagatesClaimsToOuter(): void $accepted = new $className(['foo' => 'hi', 'bar' => 5]); $this->assertSame(['foo' => 'hi', 'bar' => 5], $accepted->meta()->rawInput()); - // The branch is rendered as a nested class alongside the outer. Locate it by - // pattern-matching the file list so the assertion below can spell the full message. - $generationDir = dirname((new \ReflectionClass($className))->getFileName()); - $nestedFiles = array_values(array_filter( - glob($generationDir . DIRECTORY_SEPARATOR . '*.php'), - static fn(string $path): bool => str_contains(basename($path), $className . '_'), - )); - $nestedClassName = str_replace('.php', '', basename($nestedFiles[0])); + // The branch is rendered as a nested class alongside the outer; its uniqid-suffixed + // name is resolved so the assertion below can spell the full message. + $nestedClassName = $this->resolveNestedClassName($className); try { new $className(['foo' => 'hi', 'bar' => 'not-int']); @@ -413,8 +410,8 @@ public function testBranchLevelAdditionalPropertiesFeedsEvaluatedSetThroughCompo . 'outer unevaluated check fires', ); } catch (UnevaluatedPropertiesException $exception) { - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[extra\]$/', + $this->assertSame( + "Provided JSON for {$className} contains not allowed unevaluated properties [extra]", $exception->getMessage(), ); $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); @@ -441,8 +438,12 @@ public function testUnevaluatedPropertiesReferencedViaRef(): void new $className(['name' => 'Alice', 'count' => 'not-int']); $this->fail('$ref-resolved unevaluatedProperties schema must reject non-integer extra'); } catch (InvalidUnevaluatedPropertiesException $exception) { - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains invalid unevaluated properties/', + $this->assertSame( + <<getMessage(), ); $this->assertSame('/unevaluatedProperties', $exception->getJsonPointer()->pointer); @@ -472,8 +473,8 @@ public function testRefResolvedBranchContributesAnnotations(): void new $className(['name' => 'Alice', 'stray' => 1]); $this->fail('An undeclared key must be rejected by unevaluatedProperties: false'); } catch (UnevaluatedPropertiesException $exception) { - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[stray\]$/', + $this->assertSame( + "Provided JSON for {$className} contains not allowed unevaluated properties [stray]", $exception->getMessage(), ); $this->assertSame(['stray'], $exception->getUnevaluatedProperties()); @@ -521,12 +522,7 @@ public function testRecursiveSelfReferenceTerminates(): void // The recursive Node class is generated once and shared by both `root` and `child` // (SchemaDefinitionDictionary caches by pointer). Resolve its class name for the // exception-message assertions below. - $generationDir = dirname((new \ReflectionClass($className))->getFileName()); - $nestedFiles = array_values(array_filter( - glob($generationDir . DIRECTORY_SEPARATOR . '*.php'), - static fn(string $path): bool => str_contains(basename($path), $className . '_'), - )); - $nodeClassName = str_replace('.php', '', basename($nestedFiles[0])); + $nodeClassName = $this->resolveNestedClassName($className); try { new $className([ @@ -586,6 +582,77 @@ public function testRecursiveSelfReferenceTerminates(): void } } + /** + * When `unevaluatedProperties` appears inside a `patternProperties` value-subschema, the + * inner subschema activates its own tracking during its own generated class's + * post-processing pass — the parent's activation walk does not need to recurse into + * pattern-value subschemas. `RenderQueue::execute()` runs + * `UnevaluatedPropertiesPostProcessor::process()` on every schema separately, and the + * inner class's `needsActivation()` returns true on the very first check because the + * inner JSON directly declares `unevaluatedProperties`. + * + * - `{p_alpha: {known: "hi"}}` accepts — the nested class's declared property covers + * the sole key. + * - `{p_alpha: {known: "hi", stray: 1}}` rejects — the nested class enforces its own + * `unevaluatedProperties: false`, surfacing at the parent as + * `InvalidPatternPropertiesException` wrapping the inner + * `UnevaluatedPropertiesException`. + */ + public function testPatternPropertiesValueSubschemaEnforcesOwnUnevaluatedProperties(): void + { + $className = $this->generateClassFromFile('PatternValueSubschemaUnevaluated.json'); + + $accepted = new $className(['p_alpha' => ['known' => 'hi']]); + $this->assertSame(['p_alpha' => ['known' => 'hi']], $accepted->meta()->rawInput()); + + $nestedClassName = $this->resolveNestedClassName($className); + + $this->expectException(InvalidPatternPropertiesException::class); + $this->expectExceptionMessage( + << ['known' => 'hi', 'stray' => 1]]); + } + + /** + * When `unevaluatedProperties` appears inside an `additionalProperties` value-subschema, + * the inner subschema activates its own tracking during its own generated class's + * post-processing pass — mirroring the pattern-value case above. The parent has no + * `unevaluatedProperties` of its own; only the inner subschema does, and the inner class + * self-activates because its own JSON declares the keyword. + * + * - `{id: 1, dyn: {known: "hi"}}` accepts. + * - `{id: 1, dyn: {known: "hi", stray: 1}}` rejects — the nested class throws + * `UnevaluatedPropertiesException`, surfacing at the parent as + * `InvalidAdditionalPropertiesException`. + */ + public function testAdditionalPropertiesValueSubschemaEnforcesOwnUnevaluatedProperties(): void + { + $className = $this->generateClassFromFile('AdditionalValueSubschemaUnevaluated.json'); + + $accepted = new $className(['id' => 1, 'dyn' => ['known' => 'hi']]); + $this->assertSame( + ['id' => 1, 'dyn' => ['known' => 'hi']], + $accepted->meta()->rawInput(), + ); + + $nestedClassName = $this->resolveNestedClassName($className); + + $this->expectException(InvalidAdditionalPropertiesException::class); + $this->expectExceptionMessage( + << 1, 'dyn' => ['known' => 'hi', 'stray' => 1]]); + } + /** * A `$ref` that resolves to a schema in a file outside the provider's base directory is * represented at generation time as an `ExternalSchema` placeholder — a class the diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php index 93b049d8..cd868405 100644 --- a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -441,8 +441,8 @@ public function testAdditionalAccessorSetIsRejectedWhenUnevaluatedFalseOrphansTh new $className(['name' => 'Alice', 'foo' => 'orphan']); $this->fail('Expected construction to reject the orphan key'); } catch (UnevaluatedPropertiesException $constructorException) { - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[foo\]$/', + $this->assertSame( + "Provided JSON for {$className} contains not allowed unevaluated properties [foo]", $constructorException->getMessage(), ); $this->assertSame(['foo'], $constructorException->getUnevaluatedProperties()); @@ -460,8 +460,8 @@ public function testAdditionalAccessorSetIsRejectedWhenUnevaluatedFalseOrphansTh $additionalAccessor->set('foo', 'orphan'); $this->fail('Expected unevaluatedProperties: false to reject the orphan key'); } catch (UnevaluatedPropertiesException $setterException) { - $this->assertMatchesRegularExpression( - '/^Provided JSON for \S+ contains not allowed unevaluated properties \[foo\]$/', + $this->assertSame( + "Provided JSON for {$className} contains not allowed unevaluated properties [foo]", $setterException->getMessage(), ); $this->assertSame(['foo'], $setterException->getUnevaluatedProperties()); diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalValueSubschemaUnevaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalValueSubschemaUnevaluated.json new file mode 100644 index 00000000..a800151f --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalValueSubschemaUnevaluated.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "integer" + } + }, + "additionalProperties": { + "type": "object", + "properties": { + "known": { + "type": "string" + } + }, + "unevaluatedProperties": false + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/PatternValueSubschemaUnevaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/PatternValueSubschemaUnevaluated.json new file mode 100644 index 00000000..4ebd675d --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/PatternValueSubschemaUnevaluated.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "patternProperties": { + "^p_": { + "type": "object", + "properties": { + "known": { + "type": "string" + } + }, + "unevaluatedProperties": false + } + } +} From c0437fcc3d30d2903d7395e0ff3c3204bcfe81eb Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 9 Jul 2026 12:09:30 +0200 Subject: [PATCH 18/51] Document unevaluated keywords and fix transforming-filter interactions Adds the docs Phase 8 work (unevaluatedProperties/unevaluatedItems sections in object.rst/array.rst, per-composition propagation notes, a dedicated accessor post-processor page) plus the pre-existing sphinx warnings we hit along the way. While adding filter-companion tests, two transforming-filter regressions on the unevaluated side surfaced: the set() shim rejected already-transformed values because the pre-transform type-check was not pass-through-wired, and toArray() dropped transformed values because no filter-aware serializer override was emitted. TransformingFilterOutputTypePostProcessor and SerializationPostProcessor now iterate post-composition validators the same way they already iterate base validators, and a new UnevaluatedPropertiesSerializer template mirrors the additional-side serializer. Also updates the additional-properties accessor doc to match its actual companion signatures and drops one unused import in FilterProcessor. Co-Authored-By: Claude Opus 4.7 --- docs/source/combinedSchemas/allOf.rst | 29 ++++ docs/source/combinedSchemas/anyOf.rst | 24 +++ docs/source/combinedSchemas/if.rst | 32 ++++ docs/source/combinedSchemas/not.rst | 11 ++ docs/source/combinedSchemas/oneOf.rst | 25 +++ docs/source/complexTypes/array.rst | 151 ++++++++++++++++++ docs/source/complexTypes/object.rst | 142 ++++++++++++++++ docs/source/conf.py | 2 +- .../source/development/testInfrastructure.rst | 14 +- docs/source/examples/scenarioBasedTesting.rst | 6 +- ...itionalPropertiesAccessorPostProcessor.rst | 29 +++- ...aluatedPropertiesAccessorPostProcessor.rst | 107 +++++++++++++ docs/source/generator/postProcessor.rst | 1 + docs/source/generic/references.rst | 10 ++ docs/source/gettingStarted.rst | 2 +- .../Filter/FilterProcessor.php | 1 - .../Internal/SerializationPostProcessor.php | 63 ++++++++ ...nsformingFilterOutputTypePostProcessor.php | 11 ++ .../UnevaluatedPropertiesSerializer.phptpl | 9 ++ ...tedPropertiesAccessorPostProcessorTest.php | 110 +++++++++++++ .../TypedExtrasWithFilter.json | 12 ++ .../TypedExtrasWithTransformingFilter.json | 17 ++ 22 files changed, 787 insertions(+), 21 deletions(-) create mode 100644 docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst create mode 100644 src/SchemaProcessor/PostProcessor/Templates/Serialization/UnevaluatedPropertiesSerializer.phptpl create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithFilter.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithTransformingFilter.json diff --git a/docs/source/combinedSchemas/allOf.rst b/docs/source/combinedSchemas/allOf.rst index 8891ea1b..7712ec7c 100644 --- a/docs/source/combinedSchemas/allOf.rst +++ b/docs/source/combinedSchemas/allOf.rst @@ -110,3 +110,32 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\All See `Default values <../generic/default.html#branch-defaults-in-compositions>`__ for the full explanation. + +Property and item evaluation propagation +---------------------------------------- + +For an enclosing schema that uses `unevaluatedProperties <../complexTypes/object.html#unevaluated-properties>`__ +or `unevaluatedItems <../complexTypes/array.html#unevaluated-items>`__ (Draft 2019-09 and later), +every ``allOf`` branch always contributes to the evaluated set — ``allOf`` requires every branch +to succeed, so all branches' declarations apply. + +For an object-level composition: + +- Property names declared in each branch's ``properties`` count as evaluated. +- Names matched by each branch's ``patternProperties`` count as evaluated (per key with a passing + value). +- Names claimed by each branch's ``additionalProperties`` count as evaluated (per key with a + passing value). + +For a value-typed ``array`` composition, the analogous indices contributed by each branch's +``items``/``additionalItems``/``contains`` count as evaluated. + +.. note:: + + *Omitting* ``additionalProperties`` from a branch is **not** the same as writing + ``additionalProperties: true``. An omitted keyword produces no annotation and therefore + credits nothing to the enclosing ``unevaluatedProperties`` — only an *explicit* + ``additionalProperties`` (whether ``true`` or ``{schema}``) contributes. Two branches with + identical extras behaviour but one writing the keyword and the other omitting it will + therefore credit different evaluated sets. This is a spec-mandated distinction from + JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. diff --git a/docs/source/combinedSchemas/anyOf.rst b/docs/source/combinedSchemas/anyOf.rst index f658a43a..74a6adfc 100644 --- a/docs/source/combinedSchemas/anyOf.rst +++ b/docs/source/combinedSchemas/anyOf.rst @@ -102,3 +102,27 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\Any See `Default values <../generic/default.html#branch-defaults-in-compositions>`__ for the full explanation. + +Property and item evaluation propagation +---------------------------------------- + +For an enclosing schema that uses `unevaluatedProperties <../complexTypes/object.html#unevaluated-properties>`__ +or `unevaluatedItems <../complexTypes/array.html#unevaluated-items>`__ (Draft 2019-09 and later), +each ``anyOf`` branch contributes to the evaluated set **only if it succeeded** during the +current validation. Failed branches contribute nothing. Because the branches that succeed +depend on the actual input, the evaluated set is derived per validation call and refreshed +whenever the model is mutated. + +Each successful branch credits property names claimed by its ``properties``, ``patternProperties``, +and ``additionalProperties`` (per key with a passing value), and — on the array side — the +indices claimed by its ``items``/``additionalItems``/``contains``. + +.. note:: + + *Omitting* ``additionalProperties`` from a branch is **not** the same as writing + ``additionalProperties: true``. An omitted keyword produces no annotation and therefore + credits nothing to the enclosing ``unevaluatedProperties`` — only an *explicit* + ``additionalProperties`` (whether ``true`` or ``{schema}``) contributes. Two branches with + identical extras behaviour but one writing the keyword and the other omitting it will + therefore credit different evaluated sets. This is a spec-mandated distinction from + JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. diff --git a/docs/source/combinedSchemas/if.rst b/docs/source/combinedSchemas/if.rst index 151c83f6..03e5d6ed 100644 --- a/docs/source/combinedSchemas/if.rst +++ b/docs/source/combinedSchemas/if.rst @@ -211,3 +211,35 @@ When only a ``then`` block is present (no ``else``), the branch may not apply at See `Default values <../generic/default.html#branch-defaults-in-compositions>`__ for the full explanation. + +Property and item evaluation propagation +---------------------------------------- + +For an enclosing schema that uses `unevaluatedProperties <../complexTypes/object.html#unevaluated-properties>`__ +or `unevaluatedItems <../complexTypes/array.html#unevaluated-items>`__ (Draft 2019-09 and later), +each of the three branches (``if``, ``then``, ``else``) contributes independently: + +- ``if`` runs unconditionally. When it succeeds, its own declarations count as evaluated — + the JSON Schema 2019-09 spec treats ``if`` as a positive applicator whose annotations + survive. +- ``then`` runs only when ``if`` succeeded; it contributes if it also succeeds. +- ``else`` runs only when ``if`` failed; it contributes if it succeeds. + +At most two of the three branches actually execute per validation call, so the effective +contribution is: (``if``'s + ``then``'s) when the condition holds, or (``else``'s) when it does +not. Each contributing branch credits property names claimed by its ``properties``, +``patternProperties``, and ``additionalProperties`` (per key with a passing value), and — on the +array side — the indices claimed by its ``items``/``additionalItems``/``contains``. + +Because branch outcomes depend on the input, the evaluated set is derived per validation call +and refreshed whenever the model is mutated. + +.. note:: + + *Omitting* ``additionalProperties`` from a branch is **not** the same as writing + ``additionalProperties: true``. An omitted keyword produces no annotation and therefore + credits nothing to the enclosing ``unevaluatedProperties`` — only an *explicit* + ``additionalProperties`` (whether ``true`` or ``{schema}``) contributes. Two branches with + identical extras behaviour but one writing the keyword and the other omitting it will + therefore credit different evaluated sets. This is a spec-mandated distinction from + JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. diff --git a/docs/source/combinedSchemas/not.rst b/docs/source/combinedSchemas/not.rst index c941eb79..8c5a89b2 100644 --- a/docs/source/combinedSchemas/not.rst +++ b/docs/source/combinedSchemas/not.rst @@ -55,3 +55,14 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\Not - ``not: false`` — negation of the impossible schema; always valid. No validator is generated. - ``not: true`` — negation of the always-valid schema; always invalid. Providing any value raises a ``NotException`` at runtime. The generator also emits a warning at generation time. + +Property and item evaluation propagation +---------------------------------------- + +For an enclosing schema that uses `unevaluatedProperties <../complexTypes/object.html#unevaluated-properties>`__ +or `unevaluatedItems <../complexTypes/array.html#unevaluated-items>`__ (Draft 2019-09 and later), +``not`` is a **negative** applicator and contributes nothing. ``not`` succeeds when its inner +subschema *fails*, so any keys or indices touched by that inner subschema are, by definition, +not evaluated. The generator explicitly discards anything the inner subschema tried to record — +even an inner ``unevaluatedProperties``/``unevaluatedItems`` cannot leak into the enclosing +accumulator. diff --git a/docs/source/combinedSchemas/oneOf.rst b/docs/source/combinedSchemas/oneOf.rst index c3253279..373bfae1 100644 --- a/docs/source/combinedSchemas/oneOf.rst +++ b/docs/source/combinedSchemas/oneOf.rst @@ -114,3 +114,28 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\One See `Default values <../generic/default.html#branch-defaults-in-compositions>`__ for the full explanation and examples. + +Property and item evaluation propagation +---------------------------------------- + +For an enclosing schema that uses `unevaluatedProperties <../complexTypes/object.html#unevaluated-properties>`__ +or `unevaluatedItems <../complexTypes/array.html#unevaluated-items>`__ (Draft 2019-09 and later), +only the **single successful** ``oneOf`` branch contributes to the evaluated set. If two or more +branches match, the ``oneOf`` fails as a whole and no branch contributes. If none match, again +no branch contributes. + +The active branch credits property names claimed by its ``properties``, ``patternProperties``, +and ``additionalProperties`` (per key with a passing value), and — on the array side — the +indices claimed by its ``items``/``additionalItems``/``contains``. Because the identity of the +active branch depends on the input, the evaluated set is derived per validation call and +refreshed whenever the model is mutated. + +.. note:: + + *Omitting* ``additionalProperties`` from a branch is **not** the same as writing + ``additionalProperties: true``. An omitted keyword produces no annotation and therefore + credits nothing to the enclosing ``unevaluatedProperties`` — only an *explicit* + ``additionalProperties`` (whether ``true`` or ``{schema}``) contributes. Two branches with + identical extras behaviour but one writing the keyword and the other omitting it will + therefore credit different evaluated sets. This is a spec-mandated distinction from + JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. diff --git a/docs/source/complexTypes/array.rst b/docs/source/complexTypes/array.rst index 9d5f325f..773db1d9 100644 --- a/docs/source/complexTypes/array.rst +++ b/docs/source/complexTypes/array.rst @@ -334,6 +334,157 @@ any element satisfies the constraint). ``contains: false`` — no element could ever satisfy the constraint; any array value raises a ``ContainsException`` at runtime. The generator also emits a warning at generation time. +Unevaluated Items +----------------- + +The ``unevaluatedItems`` keyword (Draft 2019-09 and later) constrains every index that was not +evaluated by any positive sibling applicator at the same schema level. Indices claimed by +``items`` (single-schema or tuple form), by ``additionalItems`` when present, by a passing +``contains`` match, or by a **successful** composition branch (``allOf``, ``anyOf``, ``oneOf``, +``if``/``then``/``else``, ``$ref``) count as evaluated. ``not`` is a negative applicator and +contributes nothing. + +Unlike ``additionalItems``, ``unevaluatedItems`` looks across composition branches: an index +covered by a branch that ended up succeeding is credited, and an index covered only by a branch +that failed is not. + +Using ``false`` +^^^^^^^^^^^^^^^ + +Setting ``unevaluatedItems: false`` forbids any array index left uncovered by a sibling. + +.. code-block:: json + + { + "$id": "example", + "type": "object", + "properties": { + "example": { + "type": "array", + "items": [ + { + "type": "string" + } + ], + "allOf": [ + { + "items": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + ], + "unevaluatedItems": false + } + } + } + +Indices 0 and 1 are covered by the tuple items of the outer or the ``allOf`` branch. Any +further element raises an ``UnevaluatedItemsException`` with the offending indices: + +.. code-block:: none + + Provided JSON for example contains not allowed unevaluated items [#2, #3] + +The thrown exception will be a *PHPModelGenerator\\Exception\\Arrays\\UnevaluatedItemsException* +which provides the following methods to get further error details: + +.. code-block:: php + + // Get the zero-based indices of the offending items + public function getUnevaluatedItems(): array + // get the name of the property which failed + public function getPropertyName(): string + // get the value provided to the property + public function getProvidedValue() + // get the JSON pointer to the schema keyword that rejected the value + public function getJsonPointer(): JsonPointer + +Using a schema +^^^^^^^^^^^^^^ + +When ``unevaluatedItems`` is set to a schema, every unevaluated index's value must validate +against that schema: + +.. code-block:: json + + { + "$id": "example", + "type": "object", + "properties": { + "example": { + "type": "array", + "items": [ + { + "type": "string" + } + ], + "unevaluatedItems": { + "type": "integer" + } + } + } + } + +If invalid unevaluated items are provided a detailed exception is thrown containing all +violations: + +.. code-block:: none + + Invalid unevaluated items in array example: + - invalid unevaluated item #1 + * Invalid type for item of array example. Requires int, got string + +The thrown exception will be a *PHPModelGenerator\\Exception\\Arrays\\InvalidUnevaluatedItemsException* +which provides the following methods to get further error details: + +.. code-block:: php + + // returns a two-dimensional array which contains all validation exceptions grouped by item index + public function getInvalidItems(): array + // get the name of the property which failed + public function getPropertyName(): string + // get the value provided to the property + public function getProvidedValue() + // get the JSON pointer to the schema keyword that rejected the value + public function getJsonPointer(): JsonPointer + +Interaction with sibling applicators +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When a sibling applicator already claims every index, the ``unevaluatedItems`` validator has +nothing to check. The generator recognises these dead-code shapes at generation time, emits a +warning, and skips ``unevaluatedItems`` entirely: + +- ``items: false`` (or ``items: {schema}`` in single-schema form) leaves no unclaimed index at + all, because ``items`` covers every position. +- ``additionalItems: false`` alongside tuple ``items`` blocks any index past the tuple length — + every index is either tuple-covered (and evaluated) or rejected by ``additionalItems``. +- ``additionalItems: {schema}`` alongside tuple ``items`` claims every index past the tuple + length — every index is again covered. +- An implicit ``additionalItems: false`` produced by the deny setting is treated the same way. + +.. note:: + + ``contains`` credits only the indices that actually satisfy its subschema, so any index not + matched by ``contains`` remains available for ``unevaluatedItems``. When + ``minContains: 0`` is set, ``contains`` may succeed with zero matches and contributes nothing + to the evaluated set. + +.. note:: + + ``unevaluatedItems`` also accepts the boolean literal ``true``. This is a no-op — every index + is considered evaluated — and no validator is emitted. + +.. hint:: + + See `combined schemas <../toc-combinedSchemas.html>`__ for the per-keyword rules that govern + how each composition contributes to the evaluated set. + Size validation --------------- diff --git a/docs/source/complexTypes/object.rst b/docs/source/complexTypes/object.rst index f785f89d..2bdec0d9 100644 --- a/docs/source/complexTypes/object.rst +++ b/docs/source/complexTypes/object.rst @@ -345,6 +345,148 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\Object\\InvalidAdd The validation of additional properties is independently from the `implicit null <../gettingStarted.html#implicit-null>`__ setting. If you require your additional properties to accept null define a `multi type `__ with explicit null. +Unevaluated Properties +---------------------- + +The ``unevaluatedProperties`` keyword (Draft 2019-09 and later) constrains every property whose +name was not evaluated by any positive sibling applicator at the same schema level. A key is +counted as *evaluated* when it is claimed by ``properties``, matched by ``patternProperties``, +claimed by ``additionalProperties`` (when that keyword is present), or claimed by a **successful** +branch of an adjacent composition (``allOf``, ``anyOf``, ``oneOf``, ``if``/``then``/``else``, +``$ref``). ``not`` is a negative applicator and contributes nothing. + +Unlike ``additionalProperties``, ``unevaluatedProperties`` looks across composition branches: +a property covered by a branch that ended up succeeding is credited, and a property covered only +by a branch that failed is not. This makes the keyword suitable for enforcing "no leftovers" at +the outermost schema level while individual branches are free to declare their own properties. + +.. hint:: + + If you define constraints via ``unevaluatedProperties`` you may want to use the + `UnevaluatedPropertiesAccessorPostProcessor <../generator/builtin/unevaluatedPropertiesAccessorPostProcessor.html>`__ + to access and modify unevaluated properties on the model. + +Using ``false`` +^^^^^^^^^^^^^^^ + +Setting ``unevaluatedProperties: false`` forbids any property not claimed by a sibling. + +.. code-block:: json + + { + "$id": "person", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "age": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false + } + +The generated model accepts ``name`` (claimed by ``properties``) and ``age`` (claimed by the +``allOf`` branch). Any other key raises an ``UnevaluatedPropertiesException``: + +.. code-block:: none + + Provided JSON for person contains not allowed unevaluated properties [extra] + +The thrown exception will be a *PHPModelGenerator\\Exception\\Object\\UnevaluatedPropertiesException* +which provides the following methods to get further error details: + +.. code-block:: php + + // Get the list of property names that were left unevaluated + public function getUnevaluatedProperties(): array + // get the name of the property which failed + public function getPropertyName(): string + // get the value provided to the property + public function getProvidedValue() + // get the JSON pointer to the schema keyword that rejected the value + public function getJsonPointer(): JsonPointer + +Using a schema +^^^^^^^^^^^^^^ + +When ``unevaluatedProperties`` is set to a schema, every unevaluated key's value must validate +against that schema: + +.. code-block:: json + + { + "$id": "example", + "type": "object", + "properties": { + "example": { + "type": "integer" + } + }, + "unevaluatedProperties": { + "type": "string", + "maxLength": 10 + } + } + +If invalid unevaluated properties are provided a detailed exception is thrown containing all +violations: + +.. code-block:: none + + Provided JSON for example contains invalid unevaluated properties. + - invalid unevaluated property 'note' + * Value for unevaluated property must not be longer than 10 + +The thrown exception will be a *PHPModelGenerator\\Exception\\Object\\InvalidUnevaluatedPropertiesException* +which provides the following methods to get further error details: + +.. code-block:: php + + // returns a two-dimensional array which contains all validation exceptions grouped by property names + public function getNestedExceptions(): array + // get the name of the property which failed + public function getPropertyName(): string + // get the value provided to the property + public function getProvidedValue() + // get the JSON pointer to the schema keyword that rejected the value + public function getJsonPointer(): JsonPointer + +Interaction with ``additionalProperties`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When ``additionalProperties`` is present at the same schema level, it claims every key not +covered by ``properties`` or ``patternProperties`` first — leaving nothing for +``unevaluatedProperties`` to see. The generator recognises these dead-code shapes at generation +time and emits a warning, then skips the ``unevaluatedProperties`` validator entirely: + +- ``additionalProperties: true`` alongside any ``unevaluatedProperties`` value. +- ``additionalProperties: false`` alongside any ``unevaluatedProperties`` value. +- ``additionalProperties: {schema}`` alongside any ``unevaluatedProperties`` value. +- An implicit ``additionalProperties: false`` produced by the + `deny additional properties setting <../gettingStarted.html#deny-additional-properties>`__. + +Contradictory inner schemas (e.g. ``unevaluatedProperties`` combining a subschema that no value +could satisfy with an outer configuration that still allows keys to reach it) are rejected with +a ``SchemaException`` at generation time. + +.. note:: + + ``unevaluatedProperties`` also accepts the boolean literal ``true``. This is a no-op — every + property is considered evaluated — and no validator is emitted. + +.. hint:: + + See `combined schemas <../toc-combinedSchemas.html>`__ for the per-keyword rules that govern + how each composition contributes to the evaluated set. + Recursive Objects ----------------- diff --git a/docs/source/conf.py b/docs/source/conf.py index f5c84852..de346526 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -85,7 +85,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = [] # Custom sidebar templates, must be a dictionary that maps document names # to template names. diff --git a/docs/source/development/testInfrastructure.rst b/docs/source/development/testInfrastructure.rst index fe51b037..0c5dd193 100644 --- a/docs/source/development/testInfrastructure.rst +++ b/docs/source/development/testInfrastructure.rst @@ -128,13 +128,13 @@ Examples: The three available drafts are defined in the ``JsonSchemaDraft`` enum: -=========================== =========== -Constant Description -=========================== =========== -``JsonSchemaDraft::DRAFT_07`` JSON Schema Draft 7 (the baseline) -``JsonSchemaDraft::DRAFT_2019_09`` JSON Schema Draft 2019-09 -``JsonSchemaDraft::DRAFT_2020_12`` JSON Schema Draft 2020-12 -=========================== =========== +================================== ========================================== +Constant Description +================================== ========================================== +``JsonSchemaDraft::DRAFT_07`` JSON Schema Draft 7 (the baseline) +``JsonSchemaDraft::DRAFT_2019_09`` JSON Schema Draft 2019-09 +``JsonSchemaDraft::DRAFT_2020_12`` JSON Schema Draft 2020-12 +================================== ========================================== Data providers and multi-draft expansion ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/source/examples/scenarioBasedTesting.rst b/docs/source/examples/scenarioBasedTesting.rst index 461e0c5d..69d29f2c 100644 --- a/docs/source/examples/scenarioBasedTesting.rst +++ b/docs/source/examples/scenarioBasedTesting.rst @@ -168,7 +168,7 @@ We'll use the schema model generator to create a Scenario class with the followi Now we can add a scripts-section to our composer.json to create a build script which runs our **generateScenarioModels.php**: -.. code-block:: json +.. code-block:: none ... "scripts": { @@ -368,7 +368,7 @@ Yes, it was. But keep in mind: your entities are likely bigger, you may have man In a larger context you may want to structure your **scenario-schema** more user-orientated instead of representing the entities of your application one-to-one. Let's assume you extend your Petshop with subscriptions so a user can subscribe to get updates on various pets (eg. changes of the availability). Now you can go one way and add an entity *petSubscription* to the **scenario-schema** which links a pet to a user with the properties *user* and *pet* (just like a subscription entity in your code). But as we want simple scenarios we could also change the *pet* entity and add a list of subscribers to the entity in our **scenario-schema**: -.. code-block:: json +.. code-block:: none "pets": { "type": "array", @@ -388,7 +388,7 @@ In a larger context you may want to structure your **scenario-schema** more user In our **ScenarioBuilder** we extend the setupPets method to also persist our subscriptions. Now our **scenario** in a SubscriberTest can look like: -.. code-block:: json +.. code-block:: none ..., "pets": [ diff --git a/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst b/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst index 44717f2a..a8f0dd91 100644 --- a/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst +++ b/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst @@ -34,26 +34,39 @@ Generated interface with the **AdditionalPropertiesAccessorPostProcessor**: .. code-block:: php - public function setExample(float $example): static; - public function getExample(): float; + public function setExample(string $example): static; + public function getExample(): ?string; public function meta(): Meta; - public function additionalProperties(): AdditionalPropertiesAccessor; + public function additionalProperties(): ExampleAdditionalProperties; -The ``additionalProperties()`` method returns an accessor object with the following interface: +Because the example schema constrains additional values to ``string``, the generator produces a +typed companion class ``ExampleAdditionalProperties`` whose signatures are narrowed to the +declared type: .. code-block:: php + /** @return string[] */ public function getAll(): array; - public function get(string $key): mixed; - public function set(string $key, mixed $value): void; + public function get(string $key): ?string; + public function set(string $key, string $value): static; public function remove(string $key): bool; .. note:: - The methods **set** and **remove** on the accessor are only available if the `immutable setting <../../gettingStarted.html#immutable-classes>`__ is set to false. + The methods **set** and **remove** on the accessor are only available if the `immutable setting <../../gettingStarted.html#immutable-classes>`__ is set to false. Immutable models return a read-only companion exposing only ``get`` and ``getAll``. + +When ``additionalProperties`` is ``true`` (or the subschema is untyped), no companion is +generated and the accessor is the bare production-library class +``AdditionalPropertiesAccessor``. Its signatures fall back to ``mixed`` while ``set`` keeps the +fluent ``static`` return: + +.. code-block:: php -When the ``additionalProperties`` keyword provides a schema that constrains the value type, a typed companion class ``{ModelName}AdditionalProperties`` is generated that narrows the return and parameter types of the accessor methods accordingly. + public function getAll(): array; + public function get(string $key): mixed; + public function set(string $key, mixed $value): static; + public function remove(string $key): bool; **getAll**: Returns all additional properties currently part of the model as key-value pairs. Properties defined in the schema (in this case *example*) are not included. Unlike ``meta()->rawInput()``, the values returned here are the processed values — if the schema defines an object schema for additional properties, an array of object instances is returned; if a `filter <../../nonStandardExtensions/filter.html>`__ is applied, the filtered (and for transforming filters, transformed) values are returned. diff --git a/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst b/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst new file mode 100644 index 00000000..94ae15d1 --- /dev/null +++ b/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst @@ -0,0 +1,107 @@ +UnevaluatedPropertiesAccessorPostProcessor +========================================== + +.. code-block:: php + + $generator = new ModelGenerator(); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + +The **UnevaluatedPropertiesAccessorPostProcessor** adds methods to your model to work with +`unevaluated properties <../../complexTypes/object.html#unevaluated-properties>`__ (Draft 2019-09 +and later) on your objects. The post processor is only effective when the enclosing schema can +actually accumulate unevaluated keys — that is, when ``additionalProperties`` is absent or set to +``true`` at the same level. If ``additionalProperties`` is ``false`` or a ``{schema}``, every extra +key is already either rejected or claimed and validated by that keyword, so nothing ever reaches +the unevaluated bucket. In those cases the accessor emits no methods and no backing field; the +``unevaluatedProperties`` validator continues to run as a pure assertion. + +.. note:: + + If the `deny additional properties setting <../../gettingStarted.html#deny-additional-properties>`__ + is set to true the accessor is skipped for the same reason: every schema that does not define + ``additionalProperties`` behaves as if it had ``additionalProperties: false``. + +Added methods +~~~~~~~~~~~~~ + +.. code-block:: json + + { + "$id": "example", + "type": "object", + "properties": { + "example": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "string" + } + } + +Generated interface with the **UnevaluatedPropertiesAccessorPostProcessor**: + +.. code-block:: php + + public function setExample(string $example): static; + public function getExample(): ?string; + + public function unevaluatedProperties(): ExampleUnevaluatedProperties; + +Because the example schema constrains unevaluated values to ``string``, the generator produces a +typed companion class ``ExampleUnevaluatedProperties`` whose signatures are narrowed to the +declared type: + +.. code-block:: php + + /** @return string[] */ + public function getAll(): array; + public function get(string $key): ?string; + public function set(string $key, string $value): static; + public function remove(string $key): bool; + +.. note:: + + The methods **set** and **remove** on the accessor are only available if the + `immutable setting <../../gettingStarted.html#immutable-classes>`__ is set to false. Immutable + models return a read-only companion exposing only ``get`` and ``getAll``. + +When ``unevaluatedProperties`` is ``true`` (or the subschema is untyped), no companion is +generated and the accessor is the bare production-library class +``UnevaluatedPropertiesAccessor`` (or ``ImmutableUnevaluatedPropertiesAccessor`` for immutable +models). Its signatures fall back to ``mixed`` while ``set`` keeps the fluent ``static`` return: + +.. code-block:: php + + public function getAll(): array; + public function get(string $key): mixed; + public function set(string $key, mixed $value): static; + public function remove(string $key): bool; + +**getAll**: Returns all unevaluated properties currently held by the model as key-value pairs. +Properties defined in the schema (in this case *example*) and properties claimed by successful +composition branches are not included. Values are the processed values — for typed schemas, an +array of the target type; for a `filter <../../nonStandardExtensions/filter.html>`__ result, the +filtered (and for transforming filters, transformed) values. + +**get**: Returns the current value of a single unevaluated property. Returns null if the requested +property does not exist. Like ``getAll``, returns the processed value. + +**set**: Adds or updates an unevaluated property. Re-runs the enclosing schema's validation +against the new state, including composition re-evaluation, so a mutation that would move a key +into the coverage of another sibling applicator (or vice versa) is rejected up front. Throws +*RegularPropertyAsUnevaluatedPropertyException* if the key conflicts with a regularly-defined +schema property. + +**remove**: Removes an existing unevaluated property from the model. Returns true if the property +was removed, false if it did not exist. May throw a *MinPropertiesException* if removal would +produce an invalid model state. + +Serialization +~~~~~~~~~~~~~ + +When the **UnevaluatedPropertiesAccessorPostProcessor** is applied and +`serialization <../../gettingStarted.html#serialization-methods>`__ is enabled, the unevaluated +properties are merged into the serialization result. If unevaluated properties are processed via +a transforming filter, each value is serialized via the serialization method of the transforming +filter. diff --git a/docs/source/generator/postProcessor.rst b/docs/source/generator/postProcessor.rst index 6a045a05..54a94110 100644 --- a/docs/source/generator/postProcessor.rst +++ b/docs/source/generator/postProcessor.rst @@ -22,6 +22,7 @@ All added post processors will be executed after a schema was processed and befo builtin/populatePostProcessor builtin/additionalPropertiesAccessorPostProcessor builtin/patternPropertiesAccessorPostProcessor + builtin/unevaluatedPropertiesAccessorPostProcessor .. toctree:: :caption: Custom Extensions diff --git a/docs/source/generic/references.rst b/docs/source/generic/references.rst index 5ade0531..301c6377 100644 --- a/docs/source/generic/references.rst +++ b/docs/source/generic/references.rst @@ -147,3 +147,13 @@ Generated interface: If a base reference is used and the reference doesn't point to an object definition an Exception will be thrown during the model generation process: * A referenced schema on base level must provide an object definition [Citizen] + +.. note:: + + A ``$ref`` is a positive applicator. When an enclosing schema uses + `unevaluatedProperties <../complexTypes/object.html#unevaluated-properties>`__ or + `unevaluatedItems <../complexTypes/array.html#unevaluated-items>`__ (Draft 2019-09 and later), + the resolved schema's evaluated set contributes to the enclosing schema's evaluated set — + exactly as an inline branch of the same shape would. Self-referential ``$ref`` chains are + handled without infinite recursion: the generator terminates the walk when it revisits a + schema it has already processed. diff --git a/docs/source/gettingStarted.rst b/docs/source/gettingStarted.rst index a9b81402..ccdbe59c 100644 --- a/docs/source/gettingStarted.rst +++ b/docs/source/gettingStarted.rst @@ -28,7 +28,7 @@ The first parameter of the *generateModels* method must be a class implementing =========================== =========== Provider Description =========================== =========== -RecursiveDirectoryProvider Fetches all *.json files from the given source directory. Each file must contain a JSON Schema object definition on the top level +RecursiveDirectoryProvider Fetches all ``*.json`` files from the given source directory. Each file must contain a JSON Schema object definition on the top level OpenAPIv3Provider Fetches all objects defined in the #/components/schemas section of an Open API v3 spec file =========================== =========== diff --git a/src/PropertyProcessor/Filter/FilterProcessor.php b/src/PropertyProcessor/Filter/FilterProcessor.php index d33dfd1b..8d15bf74 100644 --- a/src/PropertyProcessor/Filter/FilterProcessor.php +++ b/src/PropertyProcessor/Filter/FilterProcessor.php @@ -24,7 +24,6 @@ use PHPModelGenerator\Model\Validator\EnumValidator; 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\Model\Validator\FilterValidator; diff --git a/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php index f2f119c8..3d4b2882 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php @@ -16,6 +16,7 @@ use PHPModelGenerator\Model\Validator\AdditionalPropertiesValidator; use PHPModelGenerator\Model\Validator\FilterValidator; use PHPModelGenerator\Model\Validator\PatternPropertiesValidator; +use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator; use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookResolver; use PHPModelGenerator\SchemaProcessor\Hook\SerializationHookInterface; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; @@ -48,6 +49,10 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu if (isset($json['additionalProperties']) && $json['additionalProperties'] !== false) { $this->addAdditionalPropertiesTransformingFilterSerializer($schema, $generatorConfiguration); } + + if (isset($json['unevaluatedProperties']) && $json['unevaluatedProperties'] !== false) { + $this->addUnevaluatedPropertiesTransformingFilterSerializer($schema, $generatorConfiguration); + } } /** @@ -195,6 +200,64 @@ public function addAdditionalPropertiesTransformingFilterSerializer( ); } + /** + * When unevaluated properties have a transforming filter, override + * _serializeUnevaluatedProperties on the model so the filter's serialize() runs before the + * values reach the generic serializer. Without this, transformed values (e.g. DateTime + * instances) reach the generic path unfiltered and drop to empty arrays in the output. + * + * The generic case (no transforming filter) is handled by + * SerializableTrait::_serializeUnevaluatedProperties. + */ + public function addUnevaluatedPropertiesTransformingFilterSerializer( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + ): void { + $validationProperty = null; + foreach ($schema->getPostCompositionValidators() as $validator) { + if (is_a($validator, UnevaluatedPropertiesValidator::class)) { + $validationProperty = $validator->getValidationProperty(); + break; + } + } + + $transformingFilterValidator = null; + $serializerClass = null; + $serializerMethod = null; + + if ($validationProperty) { + foreach ($validationProperty->getValidators() as $validator) { + $validator = $validator->getValidator(); + + if ( + $validator instanceof FilterValidator && + $validator->getFilter() instanceof TransformingFilterInterface + ) { + $transformingFilterValidator = $validator; + [$serializerClass, $serializerMethod] = $validator->getFilter()->getSerializer(); + } + } + } + + if (!$transformingFilterValidator) { + return; + } + + $schema->addMethod( + '_serializeUnevaluatedProperties', + new RenderedMethod( + $schema, + $generatorConfiguration, + 'Serialization/UnevaluatedPropertiesSerializer.phptpl', + [ + 'serializerClass' => $serializerClass, + 'serializerMethod' => $serializerMethod, + 'serializerOptions' => var_export($transformingFilterValidator->getFilterOptions(), true), + ], + ) + ); + } + private function addWriteOnlyExclusion( Schema $schema, GeneratorConfiguration $generatorConfiguration, diff --git a/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php index 29cd69c0..3ffa9ebe 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php @@ -12,6 +12,7 @@ use PHPModelGenerator\Model\Validator\AdditionalPropertiesValidator; use PHPModelGenerator\Model\Validator\FilterValidator; use PHPModelGenerator\Model\Validator\PatternPropertiesValidator; +use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator; use PHPModelGenerator\Utils\TypeCheck; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; use PHPModelGenerator\Utils\FilterReflection; @@ -59,6 +60,16 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu $this->processProperty($validator->getValidationProperty(), $schema, $generatorConfiguration); } } + + // unevaluatedProperties runs in the post-composition phase, not as a base validator, + // so its validation property must be picked up separately. Without this, an already- + // transformed value fed to the accessor's set() shim would fail the subschema's + // type-check because the check would only accept the raw input type. + foreach ($schema->getPostCompositionValidators() as $validator) { + if ($validator instanceof UnevaluatedPropertiesValidator) { + $this->processProperty($validator->getValidationProperty(), $schema, $generatorConfiguration); + } + } } /** diff --git a/src/SchemaProcessor/PostProcessor/Templates/Serialization/UnevaluatedPropertiesSerializer.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Serialization/UnevaluatedPropertiesSerializer.phptpl new file mode 100644 index 00000000..1ce33162 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/Serialization/UnevaluatedPropertiesSerializer.phptpl @@ -0,0 +1,9 @@ +protected function _serializeUnevaluatedProperties(int $depth, array $except, bool $emptyObjectsAsStdClass): array +{ + $serializedValues = []; + foreach ($this->_unevaluatedProperties as $key => $value) { + $serializedValues[$key] = \{{ serializerClass }}::{{ serializerMethod }}($value, {{ serializerOptions }}); + } + + return (array) $this->_getSerializedValue($serializedValues, $depth, $except, $emptyObjectsAsStdClass); +} diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php index cd868405..08bfb870 100644 --- a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -4,6 +4,7 @@ namespace PHPModelGenerator\Tests\PostProcessor; +use DateTime; use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\Object\RegularPropertyAsUnevaluatedPropertyException; use PHPModelGenerator\Exception\Object\UnevaluatedPropertiesException; @@ -187,6 +188,115 @@ public function testImmutableAccessorOmitsMutators(): void $this->assertFalse(method_exists($accessor, 'remove')); } + /** + * A non-transforming filter (here `trim`) leaves the declared PHP type untouched — the + * companion's get/getAll/set signatures must remain the schema-declared `string` rather + * than widening to include the filter's raw input type. The filter still runs at both + * construction time (extras collected into the backing field) and mutator time (set() + * routes through the shim which re-invokes the filter). + */ + public function testNonTransformingFilterKeepsCompanionTypesAtSchemaType(): void + { + $this->addPostProcessor(); + + $className = $this->generateClassFromFile( + 'TypedExtrasWithFilter.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + // Construction collects the extras and runs `trim` on each value before storing them. + $object = new $className(['name' => 'Alice', 'greeting' => ' hello ']); + $accessor = $object->unevaluatedProperties(); + + $this->assertSame('hello', $accessor->get('greeting')); + $this->assertSame(['greeting' => 'hello'], $accessor->getAll()); + + // set() routes through the shim, filter runs again on the new value. + $accessor->set('farewell', ' bye '); + $this->assertSame('bye', $accessor->get('farewell')); + + // Companion signatures — non-transforming filter must not widen the types. + $this->assertSame('string[]', $this->getReturnTypeAnnotation($accessor, 'getAll')); + $getAllReturn = $this->getReturnType($accessor, 'getAll'); + $this->assertSame('array', $getAllReturn->getName()); + $this->assertFalse($getAllReturn->allowsNull()); + + $this->assertSame('string|null', $this->getReturnTypeAnnotation($accessor, 'get')); + $getReturn = $this->getReturnType($accessor, 'get'); + $this->assertSame('string', $getReturn->getName()); + $this->assertTrue($getReturn->allowsNull()); + + $this->assertSame('string', $this->getParameterTypeAnnotation($accessor, 'set', 1)); + $this->assertSame(['string'], $this->getParameterTypeNames($accessor, 'set', 1)); + } + + /** + * A transforming filter (here `dateTime`) turns the raw input string into a DateTime + * instance at collection time. The companion's get/getAll must therefore return the + * *transformed* type, and set() must accept either the raw input type OR the transformed + * type — mirroring the AdditionalPropertiesAccessorPostProcessor contract so a caller can + * pass a DateTime directly instead of round-tripping through a formatted string. The full + * round-trip serializes back to the raw format the filter's outputFormat declared. + */ + public function testTransformingFilterNarrowsCompanionToTransformedType(): void + { + $this->addPostProcessor(); + + $className = $this->generateClassFromFile( + 'TypedExtrasWithTransformingFilter.json', + (new GeneratorConfiguration()) + ->setImmutable(false) + ->setSerialization(true), + ); + + $object = new $className(['name' => 'Late autumn', 'start' => '2020-10-10']); + $accessor = $object->unevaluatedProperties(); + + // Extras collected at construction time are transformed to DateTime instances. + $this->assertInstanceOf(DateTime::class, $accessor->get('start')); + $this->assertInstanceOf(DateTime::class, $accessor->getAll()['start']); + + // set() with a raw string routes through the filter and stores a DateTime. + $accessor->set('end', '2020-12-12'); + $this->assertInstanceOf(DateTime::class, $accessor->get('end')); + + // set() with an already-transformed value must also be accepted — the type-check on the + // unevaluated subschema's synthetic property is pass-through-wired around the + // transforming filter, so DateTime bypasses the pre-transform `is_string` guard. + $accessor->set('now', new DateTime()); + $this->assertInstanceOf(DateTime::class, $accessor->get('now')); + + // Serialization applies the filter's outputFormat and turns each DateTime back into + // the raw representation the filter accepts. + $this->assertEqualsCanonicalizing( + ['name' => 'Late autumn', 'start' => '20201010', 'end' => '20201212'], + array_intersect_key( + $object->toArray(), + ['name' => true, 'start' => true, 'end' => true], + ), + ); + + // Companion signatures — transforming filter must widen types on both read and write. + $this->assertSame('(DateTime|null)[]', $this->getReturnTypeAnnotation($accessor, 'getAll')); + $getAllReturn = $this->getReturnType($accessor, 'getAll'); + $this->assertSame('array', $getAllReturn->getName()); + $this->assertFalse($getAllReturn->allowsNull()); + + $this->assertSame('DateTime|null', $this->getReturnTypeAnnotation($accessor, 'get')); + $getReturn = $this->getReturnType($accessor, 'get'); + $this->assertSame('DateTime', $getReturn->getName()); + $this->assertTrue($getReturn->allowsNull()); + + $this->assertSame( + 'string|DateTime|null', + $this->getParameterTypeAnnotation($accessor, 'set', 1), + ); + $this->assertEqualsCanonicalizing( + ['string', 'DateTime', 'null'], + $this->getParameterTypeNames($accessor, 'set', 1), + ); + } + /** * Composition branches contribute their declared `properties` keys to the evaluated set * at runtime. The shim must reject those keys for the same reason it rejects locally- diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithFilter.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithFilter.json new file mode 100644 index 00000000..b461c018 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithFilter.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "string", + "filter": "trim" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithTransformingFilter.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithTransformingFilter.json new file mode 100644 index 00000000..dd5a1a21 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithTransformingFilter.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Ymd" + } + ] + } +} From 2b99a09e702b9ddec78ace5f6d89f765f4cc4d0f Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Sat, 11 Jul 2026 12:47:35 +0200 Subject: [PATCH 19/51] Drop reflection-based composition-tracking test Every behavioural case the reflection test asserted (allOf key contribution, anyOf per-branch success/fail, not-branch rollback, if/then/else slot layout) is covered end-to-end by UnevaluatedPropertiesValidatorTest via observable acceptance and rejection assertions. Inspecting the internal _compositionEvaluations cache added no coverage the validator tests do not already provide and coupled the test to a private field. Co-Authored-By: Claude Opus 4.7 --- ...uatedPropertiesCompositionTrackingTest.php | 222 ------------------ .../AllOfInlineBranches.json | 25 -- .../AnyOfInlineBranches.json | 26 -- .../IfThenElseInlineBranches.json | 33 --- .../NotInlineBranch.json | 19 -- 5 files changed, 325 deletions(-) delete mode 100644 tests/Basic/UnevaluatedPropertiesCompositionTrackingTest.php delete mode 100644 tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AllOfInlineBranches.json delete mode 100644 tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AnyOfInlineBranches.json delete mode 100644 tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/IfThenElseInlineBranches.json delete mode 100644 tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/NotInlineBranch.json diff --git a/tests/Basic/UnevaluatedPropertiesCompositionTrackingTest.php b/tests/Basic/UnevaluatedPropertiesCompositionTrackingTest.php deleted file mode 100644 index 30a2c0cb..00000000 --- a/tests/Basic/UnevaluatedPropertiesCompositionTrackingTest.php +++ /dev/null @@ -1,222 +0,0 @@ - — branch succeeded, inline branch, list of evaluated property names - * object — branch succeeded, nested schema instance - */ -#[ApplicableDrafts(from: JsonSchemaDraft::DRAFT_2019_09)] -class UnevaluatedPropertiesCompositionTrackingTest extends AbstractPHPModelGeneratorTestCase -{ - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - private function getCompositionEvaluations(object $instance): array - { - $reflection = new ReflectionProperty(get_class($instance), '_compositionEvaluations'); - $reflection->setAccessible(true); - - return $reflection->getValue($instance); - } - - /** - * Returns the branch slots for the single composition validator in the schema. - * - * The outer key (the validator index in baseValidators) is not assumed to be 0 — we - * take the first value so tests are not coupled to the order validators are registered. - * - * Each slot is a typed value: null (failed), true (all evaluated), array - * (inline evaluated names), or object (nested schema instance). - * - * @return array - */ - private function getSingleCompositorBranches(object $instance): array - { - $evaluations = $this->getCompositionEvaluations($instance); - $this->assertCount(1, $evaluations, 'Expected exactly one composition validator'); - - return array_values($evaluations)[0]; - } - - // ------------------------------------------------------------------------- - // Presence / absence of _compositionEvaluations - // ------------------------------------------------------------------------- - - public function testSchemaWithUnevaluatedPropertiesHasCompositionEvaluationsProperty(): void - { - $className = $this->generateClassFromFile('AllOfInlineBranches.json'); - - $this->assertTrue( - (new ReflectionClass($className))->hasProperty('_compositionEvaluations'), - '_compositionEvaluations must be emitted when unevaluatedProperties activates tracking', - ); - } - - public function testSchemaWithoutUnevaluatedPropertiesHasNoCompositionEvaluationsProperty(): void - { - // Identical allOf structure but without unevaluatedProperties — no tracking emitted. - $className = $this->generateClass(json_encode([ - 'type' => 'object', - 'allOf' => [ - ['properties' => ['foo' => ['type' => 'string']]], - ['properties' => ['bar' => ['type' => 'integer']]], - ], - ])); - - $this->assertFalse( - (new ReflectionClass($className))->hasProperty('_compositionEvaluations'), - '_compositionEvaluations must NOT be emitted when unevaluatedProperties is absent', - ); - } - - // ------------------------------------------------------------------------- - // allOf with inline branches - // ------------------------------------------------------------------------- - - public function testAllOfInlineBranchesWithBothPropertiesPresent(): void - { - $className = $this->generateClassFromFile('AllOfInlineBranches.json'); - $instance = new $className(['name' => 'Alice', 'foo' => 'hello', 'bar' => 42]); - - $branches = $this->getSingleCompositorBranches($instance); - - // The parent schema has `type: object`, so inheritPropertyType propagates that type into - // every branch, causing each branch to become a nested-object class rather than inline. - // Nested-object branches produce an object slot holding the instantiated branch object. - - // Branch 0 (foo) — allOf always succeeds; produces a nested instance - $this->assertIsObject($branches[0]); - - // Branch 1 (bar) — allOf always succeeds; produces a nested instance - $this->assertIsObject($branches[1]); - } - - public function testAllOfInlineBranchesWithNoOptionalPropertiesEvaluatesEmptySets(): void - { - $className = $this->generateClassFromFile('AllOfInlineBranches.json'); - $instance = new $className(['name' => 'Alice']); - - $branches = $this->getSingleCompositorBranches($instance); - - // Both branches succeed (no required constraints) and produce nested instances even when - // no optional properties are present — the nested object simply has no recognised values. - $this->assertIsObject($branches[0]); - $this->assertIsObject($branches[1]); - } - - // ------------------------------------------------------------------------- - // anyOf with inline branches - // ------------------------------------------------------------------------- - - public function testAnyOfInlineBranchesWithFirstBranchMatching(): void - { - $className = $this->generateClassFromFile('AnyOfInlineBranches.json'); - $instance = new $className(['kind' => 'A']); - - $branches = $this->getSingleCompositorBranches($instance); - - // Branch 0 (requires kind) — succeeds; nested instance produced - $this->assertIsObject($branches[0]); - - // Branch 1 (requires value) — fails (value absent) - $this->assertNull($branches[1]); - } - - public function testAnyOfInlineBranchesWithSecondBranchMatching(): void - { - $className = $this->generateClassFromFile('AnyOfInlineBranches.json'); - $instance = new $className(['value' => 99]); - - $branches = $this->getSingleCompositorBranches($instance); - - // Branch 0 (requires kind) — fails (kind absent) - $this->assertNull($branches[0]); - - // Branch 1 (requires value) — succeeds; nested instance produced - $this->assertIsObject($branches[1]); - } - - // ------------------------------------------------------------------------- - // not with inline branch — slot is permanently null (annotations must not leak) - // ------------------------------------------------------------------------- - - public function testNotInlineBranchSlotIsPermanentlyNullWhenNotConditionPasses(): void - { - $className = $this->generateClassFromFile('NotInlineBranch.json'); - // forbidden absent: not-condition is satisfied (object does NOT match the not-schema) - $instance = new $className(['foo' => 'hello']); - - $branches = $this->getSingleCompositorBranches($instance); - - // The not-branch slot is always null: after the not-branch runs, composition evaluations - // are unconditionally rolled back so that any annotations the not-branch wrote cannot - // propagate. The slot is then written as null regardless of whether the branch matched. - $this->assertNull($branches[0]); - } - - public function testNotInlineBranchThrowsWhenNotConditionFails(): void - { - $this->expectException(ValidationException::class); - - $className = $this->generateClassFromFile('NotInlineBranch.json'); - // forbidden present: the not-condition fails (object DOES match the not-schema) - new $className(['foo' => 'hello', 'forbidden' => 'x']); - } - - // ------------------------------------------------------------------------- - // if/then/else with inline branches — slot layout: 0=if, 1=then, 2=else - // ------------------------------------------------------------------------- - - public function testIfThenElseWithIfConditionMatching(): void - { - $className = $this->generateClassFromFile('IfThenElseInlineBranches.json'); - $instance = new $className(['kind' => 'A', 'valueA' => 'hello']); - - $branches = $this->getSingleCompositorBranches($instance); - - // Slot 0 (if) — condition matched; nested instance produced for if-branch - $this->assertIsObject($branches[0]); - - // Slot 1 (then) — ran and succeeded; nested instance produced for then-branch - $this->assertIsObject($branches[1]); - - // Slot 2 (else) — not executed; null - $this->assertNull($branches[2]); - } - - public function testIfThenElseWithIfConditionNotMatching(): void - { - $className = $this->generateClassFromFile('IfThenElseInlineBranches.json'); - $instance = new $className(['kind' => 'B', 'valueB' => 7]); - - $branches = $this->getSingleCompositorBranches($instance); - - // Slot 0 (if) — condition did not match; null - $this->assertNull($branches[0]); - - // Slot 1 (then) — not executed; null - $this->assertNull($branches[1]); - - // Slot 2 (else) — ran and succeeded; nested instance produced for else-branch - $this->assertIsObject($branches[2]); - } -} diff --git a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AllOfInlineBranches.json b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AllOfInlineBranches.json deleted file mode 100644 index 9d3ecb68..00000000 --- a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AllOfInlineBranches.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "allOf": [ - { - "properties": { - "foo": { - "type": "string" - } - } - }, - { - "properties": { - "bar": { - "type": "integer" - } - } - } - ], - "unevaluatedProperties": false -} diff --git a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AnyOfInlineBranches.json b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AnyOfInlineBranches.json deleted file mode 100644 index c30c06aa..00000000 --- a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/AnyOfInlineBranches.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "type": "object", - "anyOf": [ - { - "properties": { - "kind": { - "type": "string" - } - }, - "required": [ - "kind" - ] - }, - { - "properties": { - "value": { - "type": "integer" - } - }, - "required": [ - "value" - ] - } - ], - "unevaluatedProperties": false -} diff --git a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/IfThenElseInlineBranches.json b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/IfThenElseInlineBranches.json deleted file mode 100644 index 33d63c1f..00000000 --- a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/IfThenElseInlineBranches.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "type": "object", - "properties": { - "kind": { - "type": "string" - } - }, - "if": { - "properties": { - "kind": { - "const": "A" - } - }, - "required": [ - "kind" - ] - }, - "then": { - "properties": { - "valueA": { - "type": "string" - } - } - }, - "else": { - "properties": { - "valueB": { - "type": "integer" - } - } - }, - "unevaluatedProperties": false -} diff --git a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/NotInlineBranch.json b/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/NotInlineBranch.json deleted file mode 100644 index e4722259..00000000 --- a/tests/Schema/UnevaluatedPropertiesCompositionTrackingTest/NotInlineBranch.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "type": "object", - "properties": { - "foo": { - "type": "string" - } - }, - "not": { - "properties": { - "forbidden": { - "type": "string" - } - }, - "required": [ - "forbidden" - ] - }, - "unevaluatedProperties": false -} From 8618cd6697beab812f97e8e4264adee7856aaece Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Sat, 11 Jul 2026 12:47:55 +0200 Subject: [PATCH 20/51] Remove unreachable inline-branch harvest in composition validation The if-null-nested-schema block in generateValidatorPropertyMap() cannot execute for the composition validators that method iterates. SchemaProcessor::transferComposedPropertiesToSchema() throws SchemaException for any schema-level composed property that lacks a nested schema, and inheritPropertyType() forces branches to adopt the parent's object type so PropertyFactory routes every inline branch through createObjectProperty() and sets the nested schema. Setter-side revalidation for inline oneOf, anyOf, and if-then-else discriminators is already covered by the strict path iterating getNestedSchema()->getProperties(), asserted end-to-end by the mutability tests exercising kind and mode discriminator flips. Co-Authored-By: Claude Opus 4.7 --- .../CompositionValidationPostProcessor.php | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php index 91f89da3..6bd5974b 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php @@ -17,14 +17,8 @@ use PHPModelGenerator\Utils\RenderHelper; /** - * Class CompositionValidationPostProcessor - * - * The CompositionValidationPostProcessor adds methods to models which require composition validations on object level - * to validate the compositions. - * - * Additionally extends setter methods to also validate compositions if the updated property is part of a composition - * - * @package PHPModelGenerator\SchemaProcessor\PostProcessor\Internal + * Adds methods to models that require composition validations at object level, and extends setter + * methods to re-run composition validation when an updated property is part of a composition. */ class CompositionValidationPostProcessor extends PostProcessor { @@ -63,20 +57,14 @@ private function generateValidatorPropertyMap(Schema $schema): array } foreach ($validator->getComposedProperties() as $composedProperty) { - if ($composedProperty->getNestedSchema() === null) { - // Harvest inline branch declared property names so setter-side revalidation - // fires when those properties change. Without this, updating a property that - // only an inline branch's `properties` declares would leave _compositionEvaluations - // stale. - foreach ($composedProperty->getBranchDeclaredPropertyNames() as $propertyName) { - if (!isset($validatorPropertyMap[$propertyName])) { - $validatorPropertyMap[$propertyName] = []; - } - $validatorPropertyMap[$propertyName][] = $validatorIndex; - } - continue; - } - + // Every schema-level composition branch has a nested schema at this point: + // SchemaProcessor::transferComposedPropertiesToSchema() throws SchemaException + // for any branch that lacks one, and inheritPropertyType() forces branches to + // adopt the parent's object type so PropertyFactory routes them through + // createObjectProperty(). The branch's declared properties are thus visible + // via getNestedSchema()->getProperties(), covering setter-side revalidation + // for inline oneOf/anyOf/if-then-else discriminators without a separate + // harvest path. foreach ($composedProperty->getNestedSchema()->getProperties() as $property) { if (!isset($validatorPropertyMap[$property->getName()])) { $validatorPropertyMap[$property->getName()] = []; From 8f033eed3e780fa514ef63fd0c8243c4fb0e2422 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Sat, 11 Jul 2026 17:31:38 +0200 Subject: [PATCH 21/51] Add red tests reproducing unevaluated-keyword review findings Ten deliberately failing tests pin the spec-correct behaviour for the defects surfaced by the review of the unevaluatedProperties / unevaluatedItems implementation. Each test turns green when its fix lands: - AutoDetectedDraftSubschemaTest (new, carrying no ApplicableDrafts attribute so the default AutoDetectionDraft stays active): the draft detected from the document root's $schema URI must apply to property and nested subschemas, not only to the root level - sibling applicator crediting on the array side: tuple items, contains matches, and an explicit items: true must credit their indices to the unevaluatedItems accumulator - a composition branch carrying unevaluatedItems without the keyword on the array property itself currently renders code calling the missing collectUnevaluatedIndices() trait method - branch-level unevaluatedProperties: true and a nested-class branch's patternProperties matches must be credited to the outer accumulator - object applicators declared without an explicit type: object must not be silently dropped - unevaluatedProperties()->set() must reject a value that a successful branch's additionalProperties claims instead of committing it to the raw model data while leaving it invisible to the accessor Expected messages are pinned against wrapping formats captured from equivalent already-working paths (composition summary in direct-exception mode, nested-object wrapping); the nested class name is matched by regex where the generated class does not exist yet. Co-Authored-By: Claude Fable 5 --- tests/Basic/UnevaluatedItemsValidatorTest.php | 104 ++++++++++++++++++ .../UnevaluatedPropertiesValidatorTest.php | 74 +++++++++++++ .../Draft/AutoDetectedDraftSubschemaTest.php | 69 ++++++++++++ ...tedPropertiesAccessorPostProcessorTest.php | 49 ++++++++- .../ArrayPropertyUnevaluatedItems.json | 10 ++ .../NestedObjectUnevaluatedProperties.json | 15 +++ .../InnerUnevaluatedItemsOnlyInBranch.json | 15 +++ .../SiblingContains.json | 12 ++ .../SiblingItemsTrue.json | 10 ++ .../SiblingTupleItems.json | 14 +++ ...ranchAdditionalPropertiesClaimsExtras.json | 23 ++++ .../BranchPatternPropertiesClaim.json | 14 +++ .../BranchUnevaluatedTrueClaims.json | 14 +++ .../UntypedNestedSchemaWithUnevaluated.json | 13 +++ 14 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 tests/Draft/AutoDetectedDraftSubschemaTest.php create mode 100644 tests/Schema/AutoDetectedDraftSubschemaTest/ArrayPropertyUnevaluatedItems.json create mode 100644 tests/Schema/AutoDetectedDraftSubschemaTest/NestedObjectUnevaluatedProperties.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsOnlyInBranch.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/SiblingContains.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/SiblingItemsTrue.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleItems.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesClaimsExtras.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/BranchPatternPropertiesClaim.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/BranchUnevaluatedTrueClaims.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/UntypedNestedSchemaWithUnevaluated.json diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 7acd837b..129300b6 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -8,6 +8,7 @@ use PHPModelGenerator\Exception\Arrays\UniqueItemsException; use PHPModelGenerator\Exception\Arrays\UnevaluatedItemsException; use PHPModelGenerator\Exception\ErrorRegistryException; +use PHPModelGenerator\Exception\ComposedValue\AllOfException; use PHPModelGenerator\Exception\Generic\InvalidTypeException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\GeneratorConfiguration; @@ -190,6 +191,109 @@ public function testSchemaFormRejectionPreservesNestedInvalidTypeExceptionForEac } } + /** + * The tuple form of `items` evaluates every index it covers: index i is evaluated when + * i < count(items) and the value at i validated against items[i]. Only indices past the + * tuple remain for `unevaluatedItems` — per the 2019-09 annotation rules a sibling + * applicator's claims must be credited even without any composition keyword involved. + */ + public function testSiblingTupleItemsCreditTheirEvaluatedIndices(): void + { + $className = $this->generateClassFromFile('SiblingTupleItems.json'); + + // Index 0 is covered by the tuple — nothing is left for unevaluatedItems. + $accepted = new $className(['tags' => ['covered']]); + $this->assertSame(['covered'], $accepted->getTags()); + + // Index 1 lies past the tuple and no other applicator claims it — only that index + // may be reported as unevaluated. + try { + new $className(['tags' => ['covered', 'surplus']]); + $this->fail('Expected UnevaluatedItemsException for the index past the tuple'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + 'Provided JSON for tags contains not allowed unevaluated items [#1]', + $exception->getMessage(), + ); + $this->assertSame([1], $exception->getUnevaluatedItems()); + $this->assertSame( + '/properties/tags/unevaluatedItems', + $exception->getJsonPointer()->pointer, + ); + } + } + + /** + * A sibling `contains` evaluates exactly the indices whose values satisfy its subschema. + * Matched indices are credited to the accumulator; every other index remains unevaluated. + */ + public function testSiblingContainsCreditsOnlyMatchingIndices(): void + { + $className = $this->generateClassFromFile('SiblingContains.json'); + + // The single element matches the contains subschema and is therefore evaluated. + $accepted = new $className(['tags' => [5]]); + $this->assertSame([5], $accepted->getTags()); + + // Index 0 matches, index 1 does not — only the non-matching index is unevaluated. + try { + new $className(['tags' => [5, 'surplus']]); + $this->fail('Expected UnevaluatedItemsException for the index contains did not match'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + 'Provided JSON for tags contains not allowed unevaluated items [#1]', + $exception->getMessage(), + ); + $this->assertSame([1], $exception->getUnevaluatedItems()); + } + } + + /** + * An explicit `items: true` is an applicator: it validates (trivially) and annotates every + * index, leaving nothing for `unevaluatedItems`. This mirrors the object side, where an + * explicit `additionalProperties: true` claims every extra key — in contrast to an omitted + * keyword, which produces no annotation. + */ + public function testSiblingItemsTrueCreditsEveryIndex(): void + { + $className = $this->generateClassFromFile('SiblingItemsTrue.json'); + + $accepted = new $className(['tags' => ['anything', 42]]); + $this->assertSame(['anything', 42], $accepted->getTags()); + } + + /** + * A composition branch may carry `unevaluatedItems` even when the array property itself + * does not: `allOf: [{unevaluatedItems: {schema}}]`. Within the branch every index is + * unevaluated (the branch declares no other applicator), so the branch's subschema applies + * to every item and decides the branch outcome. + * + * The rejection surfaces through the property-level composition wrapping: the inner + * unevaluatedItems failure is swallowed by the branch's try/catch and the composition + * reports the failed branch. In direct-exception mode the composition summary carries no + * per-element details, so the message is fully static. + */ + public function testBranchOnlyUnevaluatedItemsValidatesEveryIndex(): void + { + $className = $this->generateClassFromFile('InnerUnevaluatedItemsOnlyInBranch.json'); + + // Every index satisfies the branch's unevaluatedItems schema — the branch succeeds. + $accepted = new $className(['tags' => ['alpha', 'beta']]); + $this->assertSame(['alpha', 'beta'], $accepted->getTags()); + + // A non-string item violates the branch's unevaluatedItems schema — the branch and + // therefore the allOf composition must reject the array. + $this->expectException(AllOfException::class); + $this->expectExceptionMessage( + <<<'MSG' + Invalid value for tags declined by composition constraint. + Requires to match all composition elements but matched 0 elements. + MSG, + ); + + new $className(['tags' => [5]]); + } + /** * `uniqueItems: true` is orthogonal to `unevaluatedItems`. uniqueItems failure has its own * exception identity and message; the unevaluated check does not get to fire for that diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index 75ce8e53..42bd435e 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -418,6 +418,80 @@ public function testBranchLevelAdditionalPropertiesFeedsEvaluatedSetThroughCompo } } + /** + * A branch-level `unevaluatedProperties: true` is an applicator like any other: within the + * branch every key is unevaluated, `true` validates all of them, and per the 2019-09 + * annotation rules the branch thereby claims every key on the instance. The outer + * `unevaluatedProperties: false` therefore accepts any input — including keys no other + * sibling covers. An explicit `true` must not be collapsed into the absent-keyword case, + * which produces no annotation. + */ + public function testBranchLevelUnevaluatedTrueClaimsEveryKeyForTheOuterAccumulator(): void + { + $className = $this->generateClassFromFile('BranchUnevaluatedTrueClaims.json'); + + $accepted = new $className(['foo' => 'x', 'bar' => 5]); + $this->assertSame('x', $accepted->getFoo()); + $this->assertSame(['foo' => 'x', 'bar' => 5], $accepted->meta()->rawInput()); + } + + /** + * Keys matched by a successful branch's `patternProperties` (with passing values) are + * evaluated by that branch and must be credited to the outer accumulator — also when the + * branch renders as a nested class rather than being merged or inlined. Two assertions on + * the same generated class: + * - `{x-a: "v"}` matches the branch pattern with a passing value → credited → accepted. + * - `{other: 1}` matches no pattern and no other applicator → rejected by the outer + * unevaluated check. + */ + public function testBranchLevelPatternPropertiesMatchesCreditTheOuterAccumulator(): void + { + $className = $this->generateClassFromFile('BranchPatternPropertiesClaim.json'); + + $accepted = new $className(['x-a' => 'v']); + $this->assertSame(['x-a' => 'v'], $accepted->meta()->rawInput()); + + try { + new $className(['other' => 1]); + $this->fail('Expected UnevaluatedPropertiesException for a key no branch pattern matches'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for {$className} contains not allowed unevaluated properties [other]", + $exception->getMessage(), + ); + $this->assertSame(['other'], $exception->getUnevaluatedProperties()); + } + } + + /** + * A property subschema declaring object applicators (`properties`, `unevaluatedProperties`) + * without an explicit `type: object` still applies those keywords when the instance value + * is an object — JSON Schema applicators are not gated on a type declaration. The keyword + * must not be silently dropped for untyped subschemas: the subschema behaves exactly as if + * `type: object` were declared, so the rejection surfaces through the nested-object + * wrapping. The nested class name is matched by regex because the class does not carry a + * predictable name (the class-name generator appends a uniqid). + */ + public function testUnevaluatedPropertiesAppliesWithoutExplicitObjectTypeDeclaration(): void + { + $className = $this->generateClassFromFile('UntypedNestedSchemaWithUnevaluated.json'); + + // Only the declared key is present — nothing is unevaluated inside `child`. + $accepted = new $className(['child' => ['known' => 'a']]); + $this->assertSame(['child' => ['known' => 'a']], $accepted->meta()->rawInput()); + + // `extra` is claimed by nothing inside `child` and must be rejected. + $this->expectException(NestedObjectException::class); + $this->expectExceptionMessageMatches( + <<<'REGEX' + /^Invalid nested object for property child: + - Provided JSON for .+ contains not allowed unevaluated properties \[extra\]$/ + REGEX, + ); + + new $className(['child' => ['known' => 'a', 'extra' => 1]]); + } + /** * The `unevaluatedProperties` keyword may be expressed as an inline `$ref` — the runtime * shape is a schema, so the ref-resolved schema must be applied to every extra key. Two diff --git a/tests/Draft/AutoDetectedDraftSubschemaTest.php b/tests/Draft/AutoDetectedDraftSubschemaTest.php new file mode 100644 index 00000000..cccb76d6 --- /dev/null +++ b/tests/Draft/AutoDetectedDraftSubschemaTest.php @@ -0,0 +1,69 @@ +generateClassFromFile('NestedObjectUnevaluatedProperties.json'); + $nestedClassName = $this->resolveNestedClassName($className); + + // Only the declared key is present — nothing is unevaluated inside `child`. + $accepted = new $className(['child' => ['known' => 'a']]); + $this->assertSame(['child' => ['known' => 'a']], $accepted->meta()->rawInput()); + + // `extra` is claimed by nothing inside `child` and must be rejected. + $this->expectException(NestedObjectException::class); + $this->expectExceptionMessage( + << ['known' => 'a', 'extra' => 1]]); + } + + /** + * `unevaluatedItems` on an array property subschema must be applied when the document root + * declares the 2019-09 `$schema` URI. With no sibling applicator every element is + * unevaluated — the same behaviour the explicitly-pinned-draft tests assert for this + * schema shape. + */ + public function testDetectedDraftAppliesToArrayPropertySubschemas(): void + { + $className = $this->generateClassFromFile('ArrayPropertyUnevaluatedItems.json'); + + $accepted = new $className(['tags' => []]); + $this->assertSame(['tags' => []], $accepted->meta()->rawInput()); + + $this->expectException(UnevaluatedItemsException::class); + $this->expectExceptionMessage('Provided JSON for tags contains not allowed unevaluated items [#0]'); + + new $className(['tags' => ['surplus']]); + } +} diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php index 08bfb870..e7c4bc97 100644 --- a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -5,6 +5,7 @@ namespace PHPModelGenerator\Tests\PostProcessor; use DateTime; +use PHPModelGenerator\Exception\ComposedValue\AllOfException; use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\Object\RegularPropertyAsUnevaluatedPropertyException; use PHPModelGenerator\Exception\Object\UnevaluatedPropertiesException; @@ -18,6 +19,7 @@ use PHPModelGenerator\Tests\Support\ApplicableDrafts; use PHPModelGenerator\Tests\Support\JsonSchemaDraft; use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionClass; /** * Exercises UnevaluatedPropertiesAccessorPostProcessor: the unevaluatedProperties() accessor @@ -110,6 +112,51 @@ public function testSetRejectsValuesViolatingNonTypeConstraints(): void $this->assertSame(['name' => 'Alice', 'count' => 1], $object->meta()->rawInput()); } + /** + * When a successful composition branch claims every extra key (here via the branch's + * `additionalProperties: {type: integer}`), a key routed through + * unevaluatedProperties()->set() is subject to that claim. A value violating the claim + * must be rejected — it must never be committed to the raw model data while staying + * invisible to the accessor. After the rejected write the accessor state and the raw + * model data are unchanged, and the model remains constructible from its own raw data + * view (the write must not poison round-tripping). + * + * The expected rejection is the composition re-validation of the candidate state — the + * same AllOfException the constructor raises for `{kind: "a", extra: "not-an-integer"}`. + * In direct-exception mode the composition summary carries no per-element details, so the + * message is fully constructible. + */ + public function testSetRejectsValueViolatingASuccessfulBranchClaim(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'BranchAdditionalPropertiesClaimsExtras.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['kind' => 'a']); + + try { + $object->unevaluatedProperties()->set('extra', 'not-an-integer'); + $this->fail('Expected the branch additionalProperties claim to reject the value'); + } catch (AllOfException $exception) { + $this->assertSame( + <<getMessage(), + ); + } + + $this->assertSame([], $object->unevaluatedProperties()->getAll()); + $this->assertSame(['kind' => 'a'], $object->meta()->rawInput()); + + // Round-trip guard: the model's raw data view must still satisfy its own schema. + $reconstructed = new $className($object->meta()->rawInput()); + $this->assertSame(['kind' => 'a'], $reconstructed->meta()->rawInput()); + } + /** * Keys that match a declared property name must be routed through the named setter and * not the unevaluated accessor. The shim's runtime guard rejects them with a dedicated @@ -157,7 +204,7 @@ public function testCompanionClassNamingFollowsModelClassName(): void $object = new $className(); $accessor = $object->unevaluatedProperties(); - $reflection = new \ReflectionClass($accessor); + $reflection = new ReflectionClass($accessor); $this->assertSame($className . 'UnevaluatedProperties', $reflection->getName()); $this->assertTrue($reflection->hasMethod('get')); $this->assertTrue($reflection->hasMethod('set')); diff --git a/tests/Schema/AutoDetectedDraftSubschemaTest/ArrayPropertyUnevaluatedItems.json b/tests/Schema/AutoDetectedDraftSubschemaTest/ArrayPropertyUnevaluatedItems.json new file mode 100644 index 00000000..91b27479 --- /dev/null +++ b/tests/Schema/AutoDetectedDraftSubschemaTest/ArrayPropertyUnevaluatedItems.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "type": "object", + "properties": { + "tags": { + "type": "array", + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/AutoDetectedDraftSubschemaTest/NestedObjectUnevaluatedProperties.json b/tests/Schema/AutoDetectedDraftSubschemaTest/NestedObjectUnevaluatedProperties.json new file mode 100644 index 00000000..df34ac95 --- /dev/null +++ b/tests/Schema/AutoDetectedDraftSubschemaTest/NestedObjectUnevaluatedProperties.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "type": "object", + "properties": { + "child": { + "type": "object", + "properties": { + "known": { + "type": "string" + } + }, + "unevaluatedProperties": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsOnlyInBranch.json b/tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsOnlyInBranch.json new file mode 100644 index 00000000..41a4373f --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsOnlyInBranch.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "unevaluatedItems": { + "type": "string" + } + } + ] + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/SiblingContains.json b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingContains.json new file mode 100644 index 00000000..67368637 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingContains.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "contains": { + "type": "integer" + }, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/SiblingItemsTrue.json b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingItemsTrue.json new file mode 100644 index 00000000..bd1b9d59 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingItemsTrue.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": true, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleItems.json b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleItems.json new file mode 100644 index 00000000..b38c031f --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleItems.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": [ + { + "type": "string" + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesClaimsExtras.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesClaimsExtras.json new file mode 100644 index 00000000..0b6485e4 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesClaimsExtras.json @@ -0,0 +1,23 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "kind": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } + } + ], + "unevaluatedProperties": { + "type": "string" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchPatternPropertiesClaim.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchPatternPropertiesClaim.json new file mode 100644 index 00000000..85d3a9bc --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchPatternPropertiesClaim.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "allOf": [ + { + "type": "object", + "patternProperties": { + "^x-": { + "type": "string" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchUnevaluatedTrueClaims.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchUnevaluatedTrueClaims.json new file mode 100644 index 00000000..65117674 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchUnevaluatedTrueClaims.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "foo": { + "type": "string" + } + }, + "allOf": [ + { + "unevaluatedProperties": true + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/UntypedNestedSchemaWithUnevaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/UntypedNestedSchemaWithUnevaluated.json new file mode 100644 index 00000000..3509a382 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/UntypedNestedSchemaWithUnevaluated.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "child": { + "properties": { + "known": { + "type": "string" + } + }, + "unevaluatedProperties": false + } + } +} From 1059a5d5105d4e13e222ca7f59ed1e8b9cad5314 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 23 Jul 2026 23:49:48 +0200 Subject: [PATCH 22/51] Fix transforming filters on array items and unevaluated-items mutability Array items (schema-form items, tuple-form items, and unevaluatedItems) now behave consistently with object properties: a transforming filter's output is persisted into the property's own storage, an already-transformed value is accepted directly, and mixed raw/transformed lists validate independently. TransformingFilterOutputTypePostProcessor and SerializationPostProcessor now recurse into ArrayItemValidator/ArrayTupleValidator/UnevaluatedItemsValidator the same way EnumPostProcessor already does, with a cycle guard for self-referencing schemas and a combined serializer so tuple and unevaluatedItems filters on the same property can't clobber each other's generated method name. Also fixes a validation-bypass bug surfaced by the above: _evaluatedItemIndices was never reset between separate validation passes, so a setTags() call could silently skip validating (and re-filtering) an index whose value had completely changed since a previous pass. Reset now happens before any validator runs for the property, ahead of composition validators that may credit the same slot earlier in the same pass. Migrates the remaining spl_object_hash usages in this package to spl_object_id, since all are internal identity-tracking uses (method-name suffixes, array-key deduplication) never serialized or exposed. Co-Authored-By: Claude Sonnet 5 --- .../AbstractComposedPropertyValidator.php | 2 +- src/Model/Validator/ArrayTupleValidator.php | 12 ++ .../Validator/ComposedPropertyValidator.php | 2 +- .../Validator/ExtractedMethodValidator.php | 2 +- .../Internal/SerializationPostProcessor.php | 177 ++++++++++++++++- ...nsformingFilterOutputTypePostProcessor.php | 54 +++++- .../UnevaluatedPropertiesPostProcessor.php | 10 +- ...ayItemsTransformingFilterSerializer.phptpl | 12 ++ ...edItemsTransformingFilterSerializer.phptpl | 36 ++++ src/Templates/Model.phptpl | 7 + .../Validator/UnevaluatedItems.phptpl | 4 +- src/Utils/RenderHelper.php | 22 +++ tests/Basic/UnevaluatedItemsValidatorTest.php | 181 ++++++++++++++++++ tests/Objects/ArrayPropertyTest.php | 54 ++++++ tests/Objects/TupleArrayPropertyTest.php | 43 +++++ ...tedPropertiesAccessorPostProcessorTest.php | 60 ++++++ .../ArrayPropertyWithTransformingFilter.json | 17 ++ .../TupleArrayWithTransformingFilter.json | 22 +++ .../SchemaFormWithTransformingFilter.json | 17 ++ ...luatedItemsBothWithTransformingFilter.json | 28 +++ ...nevaluatedItemsWithTransformingFilter.json | 26 +++ 21 files changed, 774 insertions(+), 14 deletions(-) create mode 100644 src/SchemaProcessor/PostProcessor/Templates/Serialization/ArrayItemsTransformingFilterSerializer.phptpl create mode 100644 src/SchemaProcessor/PostProcessor/Templates/Serialization/IndexedItemsTransformingFilterSerializer.phptpl create mode 100644 tests/Schema/ArrayPropertyTest/ArrayPropertyWithTransformingFilter.json create mode 100644 tests/Schema/TupleArrayPropertyTest/TupleArrayWithTransformingFilter.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/SchemaFormWithTransformingFilter.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleAndUnevaluatedItemsBothWithTransformingFilter.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/TupleItemsPlusUnevaluatedItemsWithTransformingFilter.json diff --git a/src/Model/Validator/AbstractComposedPropertyValidator.php b/src/Model/Validator/AbstractComposedPropertyValidator.php index 7ec8e9cc..0a007796 100644 --- a/src/Model/Validator/AbstractComposedPropertyValidator.php +++ b/src/Model/Validator/AbstractComposedPropertyValidator.php @@ -85,7 +85,7 @@ public function isNotComposition(): bool protected function initModifiedValuesMethod(): void { - $this->modifiedValuesMethod = '_getModifiedValues_' . substr(md5(spl_object_hash($this)), 0, 5); + $this->modifiedValuesMethod = '_getModifiedValues_' . substr(md5((string) spl_object_id($this)), 0, 5); } /** diff --git a/src/Model/Validator/ArrayTupleValidator.php b/src/Model/Validator/ArrayTupleValidator.php index 8c89ad3c..99e7b999 100644 --- a/src/Model/Validator/ArrayTupleValidator.php +++ b/src/Model/Validator/ArrayTupleValidator.php @@ -82,6 +82,18 @@ public function getCheck(): string return parent::getCheck(); } + /** + * The properties used to validate each tuple index, in index order. Exposed so post + * processors (e.g. TransformingFilterOutputTypePostProcessor) can recurse into them — + * mirrors ArrayItemValidator::getNestedProperty() for the tuple-form equivalent. + * + * @return PropertyInterface[] + */ + public function getTupleProperties(): array + { + return $this->tupleProperties; + } + /** * Initialize all variables which are required to execute a property names validator */ diff --git a/src/Model/Validator/ComposedPropertyValidator.php b/src/Model/Validator/ComposedPropertyValidator.php index f709ae47..bff9be5d 100644 --- a/src/Model/Validator/ComposedPropertyValidator.php +++ b/src/Model/Validator/ComposedPropertyValidator.php @@ -95,7 +95,7 @@ public function createSubsetValidator(array $branchIndices, string $methodSuffix // Regenerate the modifiedValuesMethod name so the subset validator's helper // method is distinct from the original's. $subsetValidator->modifiedValuesMethod = - '_getModifiedValues_' . substr(md5(spl_object_hash($subsetValidator)), 0, 5); + '_getModifiedValues_' . substr(md5((string) spl_object_id($subsetValidator)), 0, 5); $subsetValidator->composedProperties = $filteredProperties; $subsetValidator->templateValues = array_merge($this->templateValues, [ diff --git a/src/Model/Validator/ExtractedMethodValidator.php b/src/Model/Validator/ExtractedMethodValidator.php index ff8dfa91..85091a83 100644 --- a/src/Model/Validator/ExtractedMethodValidator.php +++ b/src/Model/Validator/ExtractedMethodValidator.php @@ -29,7 +29,7 @@ public function __construct( '_validate%s_%s_%s', str_replace(' ', '', ucfirst($property->getAttribute())), str_replace('Validator', '', substr(strrchr(static::class, '\\'), 1)), - md5(json_encode($property->getJsonSchema()->getJson()) . spl_object_hash($this)), + md5(json_encode($property->getJsonSchema()->getJson()) . (string) spl_object_id($this)), ); parent::__construct($property, $template, $templateValues, $exceptionClass, $exceptionParams); diff --git a/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php index 3d4b2882..740f72f6 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php @@ -14,8 +14,11 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator\AdditionalPropertiesValidator; +use PHPModelGenerator\Model\Validator\ArrayItemValidator; +use PHPModelGenerator\Model\Validator\ArrayTupleValidator; use PHPModelGenerator\Model\Validator\FilterValidator; use PHPModelGenerator\Model\Validator\PatternPropertiesValidator; +use PHPModelGenerator\Model\Validator\UnevaluatedItemsValidator; use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator; use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookResolver; use PHPModelGenerator\SchemaProcessor\Hook\SerializationHookInterface; @@ -64,8 +67,31 @@ private function addSerializeFunctionsForTransformingFilters( GeneratorConfiguration $generatorConfiguration, ): void { foreach ($schema->getProperties() as $property) { - foreach ($property->getValidators() as $validator) { - $validator = $validator->getValidator(); + $arrayItemValidator = null; + $arrayTupleValidator = null; + $unevaluatedItemsValidator = null; + + foreach ($property->getValidators() as $propertyValidator) { + $validator = $propertyValidator->getValidator(); + + // Array items live on a separate property from the array itself (schema-form, + // tuple-form, and unevaluatedItems each keep their own nested/tuple/validation + // property), so a transforming filter declared on an item is otherwise invisible + // to this pass — mirrors the same recursion TransformingFilterOutputTypePostProcessor + // performs for the same reason. Collected here rather than acted on immediately: + // tuple-form items and unevaluatedItems can legitimately coexist on the same + // property (a tuple covering fixed indices, unevaluatedItems covering the + // overflow — the primary intended use of the keyword alongside a tuple), and both + // must be combined into a single generated _serialize{Property}() method — two + // independent addMethod() calls with the same method name would silently + // overwrite each other (Schema::addMethod() is a plain array write). + if ($validator instanceof ArrayItemValidator) { + $arrayItemValidator = $validator; + } elseif ($validator instanceof ArrayTupleValidator) { + $arrayTupleValidator = $validator; + } elseif ($validator instanceof UnevaluatedItemsValidator) { + $unevaluatedItemsValidator = $validator; + } if ( $validator instanceof FilterValidator && @@ -92,6 +118,26 @@ private function addSerializeFunctionsForTransformingFilters( ); } } + + if ($arrayItemValidator !== null) { + // Schema-form items claims every index, which makes a sibling unevaluatedItems + // dead code at the factory level (UnevaluatedItemsValidatorFactory::isDeadCode()) + // — the two never coexist on the same property, so this stays a standalone case. + $this->addArrayItemsTransformingFilterSerializer( + $schema, + $generatorConfiguration, + $property, + $arrayItemValidator->getNestedProperty(), + ); + } elseif ($arrayTupleValidator !== null || $unevaluatedItemsValidator !== null) { + $this->addIndexedItemsTransformingFilterSerializer( + $schema, + $generatorConfiguration, + $property, + $arrayTupleValidator, + $unevaluatedItemsValidator, + ); + } } foreach ($schema->getBaseValidators() as $validator) { @@ -128,6 +174,133 @@ private function addSerializeFunctionsForTransformingFilters( } } + /** + * Returns the first FilterValidator wrapping a transforming filter among the given + * property's own validators, or null when none exists. + */ + private function findTransformingFilterValidator(PropertyInterface $property): ?FilterValidator + { + foreach ($property->getValidators() as $propertyValidator) { + $validator = $propertyValidator->getValidator(); + + if ( + $validator instanceof FilterValidator + && $validator->getFilter() instanceof TransformingFilterInterface + ) { + return $validator; + } + } + + return null; + } + + /** + * Schema-form items: every index shares the same subschema, so a single filter (if any) + * applies uniformly across the whole array. + */ + private function addArrayItemsTransformingFilterSerializer( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + PropertyInterface $arrayProperty, + PropertyInterface $nestedProperty, + ): void { + $filterValidator = $this->findTransformingFilterValidator($nestedProperty); + if ($filterValidator === null) { + return; + } + + [$serializerClass, $serializerMethod] = $filterValidator->getFilter()->getSerializer(); + + $schema->addMethod( + "_serialize{$arrayProperty->getAttribute()}", + new RenderedMethod( + $schema, + $generatorConfiguration, + join(DIRECTORY_SEPARATOR, ['Serialization', 'ArrayItemsTransformingFilterSerializer.phptpl']), + [ + 'property' => $arrayProperty, + 'serializerClass' => $serializerClass, + 'serializerMethod' => $serializerMethod, + 'serializerOptions' => var_export($filterValidator->getFilterOptions(), true), + ], + ), + ); + } + + /** + * Tuple-form items (static, per-index) and unevaluatedItems (dynamic, runtime-tracked via + * `_evaluatedItemIndices` — unlike tuple indices, which indices it evaluated can't be + * resolved to a static list at generation time) combined into a single generated method, + * since both can legitimately apply to the same property at once. + */ + private function addIndexedItemsTransformingFilterSerializer( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + PropertyInterface $arrayProperty, + ?ArrayTupleValidator $arrayTupleValidator, + ?UnevaluatedItemsValidator $unevaluatedItemsValidator, + ): void { + $indexSerializers = []; + $staticIndices = []; + + if ($arrayTupleValidator !== null) { + foreach ($arrayTupleValidator->getTupleProperties() as $index => $tupleProperty) { + $staticIndices[] = $index; + + $filterValidator = $this->findTransformingFilterValidator($tupleProperty); + if ($filterValidator === null) { + continue; + } + + [$serializerClass, $serializerMethod] = $filterValidator->getFilter()->getSerializer(); + + $indexSerializers[] = [ + 'index' => $index, + 'serializerClass' => $serializerClass, + 'serializerMethod' => $serializerMethod, + 'serializerOptions' => var_export($filterValidator->getFilterOptions(), true), + ]; + } + } + + $dynamicSerializerClass = null; + $dynamicSerializerMethod = null; + $dynamicSerializerOptions = null; + + if ($unevaluatedItemsValidator !== null) { + $filterValidator = $this->findTransformingFilterValidator( + $unevaluatedItemsValidator->getValidationProperty(), + ); + + if ($filterValidator !== null) { + [$dynamicSerializerClass, $dynamicSerializerMethod] = $filterValidator->getFilter()->getSerializer(); + $dynamicSerializerOptions = var_export($filterValidator->getFilterOptions(), true); + } + } + + if ($indexSerializers === [] && $dynamicSerializerClass === null) { + return; + } + + $schema->addMethod( + "_serialize{$arrayProperty->getAttribute()}", + new RenderedMethod( + $schema, + $generatorConfiguration, + join(DIRECTORY_SEPARATOR, ['Serialization', 'IndexedItemsTransformingFilterSerializer.phptpl']), + [ + 'property' => $arrayProperty, + 'indexSerializers' => $indexSerializers, + 'staticIndices' => $staticIndices, + 'arrayPropertyName' => $arrayProperty->getName(), + 'dynamicSerializerClass' => $dynamicSerializerClass, + 'dynamicSerializerMethod' => $dynamicSerializerMethod, + 'dynamicSerializerOptions' => $dynamicSerializerOptions, + ], + ), + ); + } + private function addSerializationHookMethod(Schema $schema, GeneratorConfiguration $generatorConfiguration): void { $schema->addMethod( diff --git a/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php index 3ffa9ebe..232369ea 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php @@ -10,8 +10,11 @@ use PHPModelGenerator\Model\Property\PropertyType; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\Validator\AdditionalPropertiesValidator; +use PHPModelGenerator\Model\Validator\ArrayItemValidator; +use PHPModelGenerator\Model\Validator\ArrayTupleValidator; use PHPModelGenerator\Model\Validator\FilterValidator; use PHPModelGenerator\Model\Validator\PatternPropertiesValidator; +use PHPModelGenerator\Model\Validator\UnevaluatedItemsValidator; use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator; use PHPModelGenerator\Utils\TypeCheck; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; @@ -32,6 +35,11 @@ * FilterProcessor does NOT call it because the TypeCheckValidator may not yet exist at * filter-processing time (composition case where the type comes from a sibling allOf branch). * + * Array items (schema-form `items`, tuple-form `items`, and `unevaluatedItems`) are widened the + * same way object properties are: each lives on its own validation property reachable only by + * recursing into ArrayItemValidator/ArrayTupleValidator/UnevaluatedItemsValidator, mirroring the + * recursion EnumPostProcessor already performs for the same reason. + * * Output type formula: * accepted = filter callable's first-parameter types ([] = accepts all) * bypass_names = base_names − non-null accepted ([] when accepted is empty) @@ -73,23 +81,65 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu } /** + * @param array $seenPropertyIds Object ids of properties already visited in the + * current recursive walk, threaded by reference so + * a `$ref` cycle can be detected across calls. + * * @throws ReflectionException */ private function processProperty( PropertyInterface $property, Schema $schema, GeneratorConfiguration $generatorConfiguration, + array &$seenPropertyIds = [], ): void { - // Find the FilterValidator whose filter implements TransformingFilterInterface. + // A `$ref` cycle (e.g. `{type: array, items: {$ref: "#"}}`) resolves to the identical + // property instance on every level of the cycle, so recursing into array-item + // validators below would otherwise revisit it indefinitely. Required, not defensive — + // confirmed via MultiTypePropertyTest::testValidRecursiveMultiType's + // RecursiveMultiTypeProperty.json fixture, which stack-overflows without this guard. + $propertyId = spl_object_id($property); + if (isset($seenPropertyIds[$propertyId])) { + return; + } + $seenPropertyIds[$propertyId] = true; + + // Find the FilterValidator whose filter implements TransformingFilterInterface, and + // recurse into any array-item validator's own validation property along the way — + // array items live on a separate property from the array itself (the same reason + // AdditionalProperties/PatternProperties/UnevaluatedProperties validation properties + // are picked up separately below), so they are otherwise invisible to this pass. + // Mirrors the recursion EnumPostProcessor already performs via + // ArrayItemValidator::getNestedProperty(). $transformingFilterValidator = null; foreach ($property->getValidators() as $propertyValidator) { $validator = $propertyValidator->getValidator(); + + if ($validator instanceof ArrayItemValidator) { + $this->processProperty( + $validator->getNestedProperty(), + $schema, + $generatorConfiguration, + $seenPropertyIds, + ); + } elseif ($validator instanceof ArrayTupleValidator) { + foreach ($validator->getTupleProperties() as $tupleProperty) { + $this->processProperty($tupleProperty, $schema, $generatorConfiguration, $seenPropertyIds); + } + } elseif ($validator instanceof UnevaluatedItemsValidator) { + $this->processProperty( + $validator->getValidationProperty(), + $schema, + $generatorConfiguration, + $seenPropertyIds, + ); + } + if ( $validator instanceof FilterValidator && $validator->getFilter() instanceof TransformingFilterInterface ) { $transformingFilterValidator = $validator; - break; } } diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php index e7f6a690..12cea24e 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -53,14 +53,14 @@ class UnevaluatedPropertiesPostProcessor extends PostProcessor private int $slotKeyCounter = 0; /** - * Object hashes of composition validators already activated in the current property + * Object ids of composition validators already activated in the current property * walk. Required (not defensive) — a self-referencing schema such as * `{type: array, allOf: [{$ref: "#"}], unevaluatedItems: false}` produces a composition * validator whose composed property's wrapped property carries the same composition * validator instance. Without this short-circuit, `activateArrayComposition()` would * recurse indefinitely. * - * @var array + * @var array */ private array $activatedCompositions = []; @@ -224,11 +224,11 @@ private function activateArrayComposition( AbstractComposedPropertyValidator $compositionValidator, PropertyInterface $parentProperty, ): void { - $compositionHash = spl_object_hash($compositionValidator); - if (isset($this->activatedCompositions[$compositionHash])) { + $compositionId = spl_object_id($compositionValidator); + if (isset($this->activatedCompositions[$compositionId])) { return; } - $this->activatedCompositions[$compositionHash] = true; + $this->activatedCompositions[$compositionId] = true; $compositionValidator->enableEvaluationTracking(); $compositionValidator->setSlotKey($parentProperty->getName() . '_' . $this->slotKeyCounter++); diff --git a/src/SchemaProcessor/PostProcessor/Templates/Serialization/ArrayItemsTransformingFilterSerializer.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Serialization/ArrayItemsTransformingFilterSerializer.phptpl new file mode 100644 index 00000000..ed823d63 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/Serialization/ArrayItemsTransformingFilterSerializer.phptpl @@ -0,0 +1,12 @@ +/** + * serialize the property {{ property.getAttribute() }} (schema-form items, uniform filter) + */ +protected function _serialize{{ viewHelper.ucfirst(property.getAttribute()) }}(): array +{ + $serialized = []; + foreach ($this->{{ property.getAttribute(true) }} as $index => $value) { + $serialized[$index] = \{{ serializerClass }}::{{ serializerMethod }}($value, {{ serializerOptions }}); + } + + return $serialized; +} diff --git a/src/SchemaProcessor/PostProcessor/Templates/Serialization/IndexedItemsTransformingFilterSerializer.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Serialization/IndexedItemsTransformingFilterSerializer.phptpl new file mode 100644 index 00000000..c05660fb --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/Serialization/IndexedItemsTransformingFilterSerializer.phptpl @@ -0,0 +1,36 @@ +/** + * serialize the property {{ property.getAttribute() }} — static tuple-index filters and/or a + * dynamic filter for indices unevaluatedItems credited at runtime, combined into a single method + * so the two never clobber each other's generated method name. + */ +protected function _serialize{{ viewHelper.ucfirst(property.getAttribute()) }}(): array +{ + $serialized = $this->{{ property.getAttribute(true) }}; + + {% foreach indexSerializers as indexSerializer %} + if (array_key_exists({{ indexSerializer.index }}, $serialized)) { + $serialized[{{ indexSerializer.index }}] = \{{ indexSerializer.serializerClass }}::{{ indexSerializer.serializerMethod }}( + $serialized[{{ indexSerializer.index }}], + {{ indexSerializer.serializerOptions }}, + ); + } + {% endforeach %} + + {% if dynamicSerializerClass %} + foreach (array_keys($this->_evaluatedItemIndices['{{ arrayPropertyName }}'] ?? []) as $index) { + {% if staticIndices %} + // Indices already handled by a static tuple filter above must not also run + // through the dynamic unevaluatedItems filter. + if (in_array($index, {{ viewHelper.varExportArray(staticIndices) }}, true)) { + continue; + } + {% endif %} + $serialized[$index] = \{{ dynamicSerializerClass }}::{{ dynamicSerializerMethod }}( + $serialized[$index], + {{ dynamicSerializerOptions }}, + ); + } + {% endif %} + + return $serialized; +} diff --git a/src/Templates/Model.phptpl b/src/Templates/Model.phptpl index 99b1748d..a40e124d 100644 --- a/src/Templates/Model.phptpl +++ b/src/Templates/Model.phptpl @@ -235,6 +235,13 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v */ protected function _validate{{ viewHelper.ucfirst(property.getAttribute()) }}($value, array $modelData) { + {% if viewHelper.hasUnevaluatedItemsValidator(property) %} + // Reset before any validator runs (including compositions, which may credit + // indices into this same slot during this pass) so a previous, unrelated + // validation pass's credits never leak into this one. + $this->_evaluatedItemIndices['{{ property.getName() }}'] = []; + {% endif %} + {% foreach property.getOrderedValidators() as validator %} {{ viewHelper.renderValidator(validator, schema) }} {% endforeach %} diff --git a/src/Templates/Validator/UnevaluatedItems.phptpl b/src/Templates/Validator/UnevaluatedItems.phptpl index 3d77ba83..d6b15281 100644 --- a/src/Templates/Validator/UnevaluatedItems.phptpl +++ b/src/Templates/Validator/UnevaluatedItems.phptpl @@ -1,4 +1,4 @@ -is_array($value) && (function ($items) use (&$invalidItems, $modelData): bool { +is_array($value) && (function (&$items) use (&$invalidItems, $modelData): bool { {% if generatorConfiguration.collectErrors() %} $originalErrorRegistry = $this->_errorRegistry; {% endif %} @@ -12,7 +12,7 @@ is_array($value) && (function ($items) use (&$invalidItems, $modelData): bool { foreach ($unevaluatedIndices as $index) { try { - $value = $items[$index]; + $value = &$items[$index]; {% if generatorConfiguration.collectErrors() %} $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); diff --git a/src/Utils/RenderHelper.php b/src/Utils/RenderHelper.php index 135956a1..f1cb8f03 100644 --- a/src/Utils/RenderHelper.php +++ b/src/Utils/RenderHelper.php @@ -10,6 +10,7 @@ use PHPModelGenerator\Model\Validator\ExtractedMethodValidator; use PHPModelGenerator\Model\Validator\PropertyTemplateValidator; use PHPModelGenerator\Model\Validator\PropertyValidatorInterface; +use PHPModelGenerator\Model\Validator\UnevaluatedItemsValidator; /** * Class RenderHelper @@ -182,6 +183,27 @@ public function isMutableBaseValidator(GeneratorConfiguration $generatorConfigur return !$generatorConfiguration->isImmutable() && $isBaseValidator; } + /** + * True when the property carries an UnevaluatedItemsValidator among its own validators. + * + * `_evaluatedItemIndices[$propertyName]` must be reset to an empty array before this + * property's validator chain runs (ahead of any composition validator, which may credit + * indices into the same slot during this same pass) — otherwise indices credited by a + * previous, unrelated validation pass (e.g. an earlier setter call) would incorrectly count + * as "already evaluated" for a completely different array value, silently skipping both + * validation and any transforming filter for that index. + */ + public function hasUnevaluatedItemsValidator(PropertyInterface $property): bool + { + foreach ($property->getValidators() as $propertyValidator) { + if ($propertyValidator->getValidator() instanceof UnevaluatedItemsValidator) { + return true; + } + } + + return false; + } + public static function varExportArray(array $values): string { return preg_replace('(\d+\s=>)', '', var_export($values, true)); diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 129300b6..81f10f32 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -4,6 +4,7 @@ namespace PHPModelGenerator\Tests\Basic; +use DateTime; use PHPModelGenerator\Exception\Arrays\InvalidUnevaluatedItemsException; use PHPModelGenerator\Exception\Arrays\UniqueItemsException; use PHPModelGenerator\Exception\Arrays\UnevaluatedItemsException; @@ -756,4 +757,184 @@ public function testOneOfBranchesOfDifferentTupleLengthsControlEvaluatedSet(): v $this->assertSame([3], $exception->getUnevaluatedItems()); } } + + /** + * A transforming filter (here `dateTime`) declared inside `unevaluatedItems`'s subschema + * behaves like a filter on any other property: the transformed value is persisted (the + * getter reports `DateTime` instances, not the raw strings), an already-transformed value + * passed directly is accepted (the type check is widened via + * `TransformingFilterOutputTypePostProcessor`, which recurses into + * `UnevaluatedItemsValidator::getValidationProperty()` the same way it already does for + * `UnevaluatedPropertiesValidator`), and a mixed list of raw and already-transformed values + * is accepted since each index is validated independently. + * + * Serialization applies the filter's outputFormat to every index credited to the + * unevaluatedItems validator, turning each DateTime back into the raw representation the + * filter accepts. + * + * An invalid date string still fails the filter's own validation. + */ + public function testTransformingFilterPersistsAndAcceptsAlreadyTransformedValues(): void + { + $className = $this->generateClassFromFile( + 'SchemaFormWithTransformingFilter.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + // Valid raw date strings pass the filter and the transformed value is persisted. + $accepted = new $className(['tags' => ['2020-10-10', '2020-12-12']]); + $this->assertEquals( + [new DateTime('2020-10-10'), new DateTime('2020-12-12')], + $accepted->getTags(), + ); + $this->assertSame(['2020-10-10', '2020-12-12'], $accepted->meta()->rawInput()['tags']); + + $this->assertSame(['tags' => ['20201010', '20201212']], $accepted->toArray()); + $decoded = json_decode($accepted->toJSON(), true); + $this->assertSame(['tags' => ['20201010', '20201212']], $decoded); + + // An already-transformed DateTime passed directly is accepted and persisted as-is. + $alreadyTransformed = new $className(['tags' => [new DateTime('2020-10-10')]]); + $this->assertEquals([new DateTime('2020-10-10')], $alreadyTransformed->getTags()); + $this->assertEquals([new DateTime('2020-10-10')], $alreadyTransformed->meta()->rawInput()['tags']); + + // A mixed list of raw and already-transformed values is accepted — each index is + // validated independently. + $mixed = new $className(['tags' => ['2020-10-10', new DateTime('2020-12-12')]]); + $this->assertEquals( + [new DateTime('2020-10-10'), new DateTime('2020-12-12')], + $mixed->getTags(), + ); + + // The setter must exercise the same validator chain as construction: a raw date string + // is transformed and persisted, and an already-transformed value is accepted directly. + $accepted->setTags(['2020-01-01']); + $this->assertEquals([new DateTime('2020-01-01')], $accepted->getTags()); + + $accepted->setTags([new DateTime('2020-02-02')]); + $this->assertEquals([new DateTime('2020-02-02')], $accepted->getTags()); + + // An invalid date string still fails the filter's own validation. + try { + new $className(['tags' => ['not-a-date']]); + $this->fail('Expected an exception for an invalid date string'); + } catch (ErrorRegistryException $exception) { + $this->assertSame( + <<<'MSG' + Invalid unevaluated items in array tags: + - invalid unevaluated item #0 + * Invalid value for property unevaluated item denied by filter dateTime: Invalid Date Time value "not-a-date" + MSG, + $exception->getMessage(), + ); + } + } + + /** + * `_evaluatedItemIndices['tags']` records which indices the unevaluatedItems validator + * credited so composition/serialization can read it back — but it must never leak from one + * validation pass into the next. Without a reset before the validator chain runs, a + * `setTags()` call would see stale credits from a previous pass and skip validating (and, + * with a transforming filter, skip re-filtering) indices whose value has since completely + * changed. Uses a plain type check with no filter to prove this is a general unevaluatedItems + * mutability issue, not something specific to the transforming-filter interaction. + */ + public function testSetterRevalidatesEveryIndexRegardlessOfPriorPassCredits(): void + { + $className = $this->generateClassFromFile( + 'NoOtherConstraintsSchema.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(['tags' => ['alpha', 'beta']]); + $this->assertSame(['alpha', 'beta'], $object->getTags()); + + // A value that would have been valid at construction time must still be rejected when + // set later — proving the same index isn't silently exempted from validation because a + // previous, unrelated array happened to validate successfully at that position. + try { + $object->setTags([42]); + $this->fail('Expected an exception for an integer where unevaluatedItems requires a string'); + } catch (ErrorRegistryException $exception) { + $this->assertSame( + <<<'MSG' + Invalid unevaluated items in array tags: + - invalid unevaluated item #0 + * Invalid type for unevaluated item. Requires string, got integer + MSG, + $exception->getMessage(), + ); + } + + // The rejected setter call must leave the object's state unchanged. + $this->assertSame(['alpha', 'beta'], $object->getTags()); + + // A genuinely valid replacement is still accepted. + $object->setTags(['gamma']); + $this->assertSame(['gamma'], $object->getTags()); + } + + /** + * Which indices unevaluatedItems credits is only known at runtime (tracked in + * `_evaluatedItemIndices`) — unlike a tuple index, it can't be resolved to a static list at + * generation time. Index 0 is claimed by an `allOf` branch's tuple-form `items` (no filter, + * passes through unchanged); index 1 is left over for `unevaluatedItems` (filtered). Proves + * the generated serializer only transforms the indices actually credited to + * unevaluatedItems, not every index in the array. + * + * Uses composition-based crediting (`allOf` branch), not a direct sibling `items` tuple, + * because direct-sibling tuple crediting is a separately tracked, currently-broken + * interaction (see testSiblingTupleItemsCreditTheirEvaluatedIndices) unrelated to this test. + */ + public function testSerializationOfUnevaluatedItemsWithTransformingFilterOnlyAffectsCreditedIndices(): void + { + $className = $this->generateClassFromFile( + 'TupleItemsPlusUnevaluatedItemsWithTransformingFilter.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + $object = new $className(['tags' => ['plain', '2020-10-10']]); + + $this->assertSame(['tags' => ['plain', '20201010']], $object->toArray()); + + $decoded = json_decode($object->toJSON(), true); + $this->assertSame(['tags' => ['plain', '20201010']], $decoded); + } + + /** + * A tuple index and unevaluatedItems on the same array property, each with their own + * transforming filter, must not clobber each other: `SerializationPostProcessor` generates + * one `_serialize{Property}()` method per property, and independently generating one from + * the tuple validator and another from the unevaluatedItems validator would silently + * overwrite whichever ran last (`Schema::addMethod()` is a plain array write, not a merge). + * Both filters must be reflected in the serialized output: index 0 (tuple, `Ymd`) and + * index 1 (unevaluatedItems, `Y-m-d`) end up in visibly different formats, proving neither + * one was silently dropped. + * + * Uses direct-sibling `items`/`unevaluatedItems` (not composition) because that is the only + * shape that reaches `SerializationPostProcessor`'s array-item recursion at all — composition + * branches are a separate, not-yet-covered case (the branch's own tuple validator lives on + * the branch's nested property, invisible to this post processor). As a side effect this + * also exercises the direct-sibling tuple-crediting gap tracked elsewhere + * (`testSiblingTupleItemsCreditTheirEvaluatedIndices`): `_evaluatedItemIndices` ends up + * including the tuple-covered index too, since that credit-tracking bug is unrelated to and + * not fixed by this test. This test still passes despite it, because the combined serializer + * defensively skips indices already handled by the static tuple branch before running the + * dynamic unevaluatedItems branch — proving that defense actually works, not just that the + * two "happen" not to collide. + */ + public function testSerializationCombinesTupleAndUnevaluatedItemsFiltersWithoutClobbering(): void + { + $className = $this->generateClassFromFile( + 'SiblingTupleAndUnevaluatedItemsBothWithTransformingFilter.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + $object = new $className(['tags' => ['2020-10-10', '2020-12-12']]); + + $this->assertSame(['tags' => ['20201010', '2020-12-12']], $object->toArray()); + + $decoded = json_decode($object->toJSON(), true); + $this->assertSame(['tags' => ['20201010', '2020-12-12']], $decoded); + } } diff --git a/tests/Objects/ArrayPropertyTest.php b/tests/Objects/ArrayPropertyTest.php index 2d7d1645..96812e93 100644 --- a/tests/Objects/ArrayPropertyTest.php +++ b/tests/Objects/ArrayPropertyTest.php @@ -4,6 +4,7 @@ namespace PHPModelGenerator\Tests\Objects; +use DateTime; use PHPModelGenerator\Exception\Arrays\InvalidItemException; use PHPModelGenerator\Exception\Arrays\MaxItemsException; use PHPModelGenerator\Exception\Arrays\MinItemsException; @@ -927,4 +928,57 @@ public static function invalidMaxItemsValueDataProvider(): array 'boolean' => [true], ]; } + + /** + * A transforming filter (here `dateTime`) declared on a schema-form `items` subschema + * persists the transformed value (the getter reports `DateTime` instances, not the raw + * strings) and accepts an already-transformed value passed directly — the item's own + * TypeCheckValidator is widened by TransformingFilterOutputTypePostProcessor, which recurses + * into ArrayItemValidator::getNestedProperty() to reach it. A mixed list of raw and + * already-transformed values is accepted since each index is validated independently. + * + * Serialization applies the filter's outputFormat to every transformed item, turning each + * DateTime back into the raw representation the filter accepts — mirrors the object-property + * equivalent (UnevaluatedPropertiesAccessorPostProcessorTest's transforming-filter companion + * test). Without a dedicated per-item serializer, DateTime has no public properties for the + * generic fallback to pick up and each item would silently serialize to an empty array + * instead. + */ + public function testTransformingFilterOnArrayItemsPersistsAndAcceptsAlreadyTransformedValues(): void + { + $className = $this->generateClassFromFile( + 'ArrayPropertyWithTransformingFilter.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + $accepted = new $className(['tags' => ['2020-10-10', '2020-12-12']]); + $this->assertEquals( + [new DateTime('2020-10-10'), new DateTime('2020-12-12')], + $accepted->getTags(), + ); + // The raw input view stays untransformed — only the property's own storage is affected. + $this->assertSame(['2020-10-10', '2020-12-12'], $accepted->meta()->rawInput()['tags']); + + $this->assertSame(['tags' => ['20201010', '20201212']], $accepted->toArray()); + $decoded = json_decode($accepted->toJSON(), true); + $this->assertSame(['tags' => ['20201010', '20201212']], $decoded); + + $alreadyTransformed = new $className(['tags' => [new DateTime('2020-10-10')]]); + $this->assertEquals([new DateTime('2020-10-10')], $alreadyTransformed->getTags()); + $this->assertEquals([new DateTime('2020-10-10')], $alreadyTransformed->meta()->rawInput()['tags']); + + $mixed = new $className(['tags' => ['2020-10-10', new DateTime('2020-12-12')]]); + $this->assertEquals( + [new DateTime('2020-10-10'), new DateTime('2020-12-12')], + $mixed->getTags(), + ); + + // The setter must exercise the same validator chain as construction: a raw date string + // is transformed and persisted, and an already-transformed value is accepted directly. + $accepted->setTags(['2020-01-01']); + $this->assertEquals([new DateTime('2020-01-01')], $accepted->getTags()); + + $accepted->setTags([new DateTime('2020-02-02')]); + $this->assertEquals([new DateTime('2020-02-02')], $accepted->getTags()); + } } diff --git a/tests/Objects/TupleArrayPropertyTest.php b/tests/Objects/TupleArrayPropertyTest.php index d9d7256a..ac6af5b9 100644 --- a/tests/Objects/TupleArrayPropertyTest.php +++ b/tests/Objects/TupleArrayPropertyTest.php @@ -4,6 +4,7 @@ namespace PHPModelGenerator\Tests\Objects; +use DateTime; use PHPModelGenerator\Exception\Arrays\InvalidTupleException; use PHPModelGenerator\Exception\FileSystemException; use PHPModelGenerator\Exception\RenderException; @@ -434,4 +435,46 @@ public static function invalidRecursiveTupleDataProvider(): array 'one level nested - invalid nested second tuple' => [['abc', ['abc', 1]]], ]; } + + /** + * A transforming filter (here `dateTime`) declared on one tuple index persists the + * transformed value and accepts an already-transformed value passed directly for that + * index — TransformingFilterOutputTypePostProcessor recurses into + * ArrayTupleValidator::getTupleProperties() to widen the affected index's TypeCheckValidator + * without touching the other, unfiltered index. + * + * Serialization applies the filter's outputFormat only to the tuple index that declares it + * (index 0); the plain second index passes through unchanged — proving the per-index + * serializer doesn't over-apply the filter to indices that never had one. + */ + public function testTransformingFilterOnTupleIndexPersistsAndAcceptsAlreadyTransformedValue(): void + { + $className = $this->generateClassFromFile( + 'TupleArrayWithTransformingFilter.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + $accepted = new $className(['tags' => ['2020-10-10', 'plain']]); + $this->assertEquals([new DateTime('2020-10-10'), 'plain'], $accepted->getTags()); + // The raw input view stays untransformed — only the property's own storage is affected. + $this->assertSame(['2020-10-10', 'plain'], $accepted->meta()->rawInput()['tags']); + + $this->assertSame(['tags' => ['20201010', 'plain']], $accepted->toArray()); + $decoded = json_decode($accepted->toJSON(), true); + $this->assertSame(['tags' => ['20201010', 'plain']], $decoded); + + $alreadyTransformed = new $className(['tags' => [new DateTime('2020-10-10'), 'plain']]); + $this->assertEquals([new DateTime('2020-10-10'), 'plain'], $alreadyTransformed->getTags()); + $this->assertEquals( + [new DateTime('2020-10-10'), 'plain'], + $alreadyTransformed->meta()->rawInput()['tags'], + ); + + // The setter must exercise the same validator chain as construction. + $accepted->setTags(['2020-01-01', 'plain']); + $this->assertEquals([new DateTime('2020-01-01'), 'plain'], $accepted->getTags()); + + $accepted->setTags([new DateTime('2020-02-02'), 'plain']); + $this->assertEquals([new DateTime('2020-02-02'), 'plain'], $accepted->getTags()); + } } diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php index e7c4bc97..2a419e28 100644 --- a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -7,6 +7,7 @@ use DateTime; use PHPModelGenerator\Exception\ComposedValue\AllOfException; use PHPModelGenerator\Exception\ErrorRegistryException; +use PHPModelGenerator\Exception\Object\MinPropertiesException; use PHPModelGenerator\Exception\Object\RegularPropertyAsUnevaluatedPropertyException; use PHPModelGenerator\Exception\Object\UnevaluatedPropertiesException; use PHPModelGenerator\Exception\ValidationException; @@ -759,6 +760,65 @@ public function testRemoveDeletesKeyFromBackingFieldAndRawInput(): void $this->assertSame(['limit' => 7], $accessor->getAll()); } + /** + * `_removeUnevaluatedProperty()` guards against dropping the total property count below + * `minProperties`, mirroring `_removeAdditionalProperty()`'s equivalent guard but through + * the separate `UnevaluatedPropertiesAccessorPostProcessor` / + * `RemoveUnevaluatedProperty.phptpl` pair — the two accessors are structurally parallel but + * are independently generated methods with independent coverage. + * + * On a single instance, constructed at exactly `minProperties` (3): + * - removing an extra would drop the count to 2 < 3 → rejected, and the rejected call + * must roll the model state back to its pre-call values. + * - the object must remain fully usable after the rollback: adding a property back above + * the minimum via `set()` and then removing one (leaving exactly `minProperties`) must + * succeed — proving the rejected call left no internal state (validation caches, + * composition state) corrupted, not just that the getters still report the old values. + * - removing a key that was never set is a no-op (`false`, no exception) even while + * sitting exactly at `minProperties` — the existence check must run before the + * `minProperties` guard, not after, otherwise a harmless no-op removal would be + * rejected by a count check it never needed to reach. + */ + public function testRemoveRejectsCountDropBelowMinPropertiesAndRollsBack(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'MinPropertiesAndUnevaluatedSchema.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['name' => 'Alice', 'a' => 1, 'b' => 2]); + $accessor = $object->unevaluatedProperties(); + + try { + $accessor->remove('a'); + $this->fail('Expected MinPropertiesException'); + } catch (MinPropertiesException $exception) { + $this->assertSame( + "Provided object for {$className} must not contain less than 3 properties, 2 properties provided", + $exception->getMessage(), + ); + } + + // The rejected removal must roll the model state back to its pre-call values. + $this->assertSame(['a' => 1, 'b' => 2], $accessor->getAll()); + $this->assertSame(['name' => 'Alice', 'a' => 1, 'b' => 2], $object->meta()->rawInput()); + + // The object stays usable: adding a property pushes the count back above the minimum, + // so removing one now leaves exactly `minProperties` and must succeed. + $accessor->set('c', 3); + $this->assertTrue($accessor->remove('c')); + $this->assertSame(['a' => 1, 'b' => 2], $accessor->getAll()); + $this->assertSame(['name' => 'Alice', 'a' => 1, 'b' => 2], $object->meta()->rawInput()); + + // Back at exactly `minProperties` (3). Removing a key that was never set must return + // false without ever consulting the minProperties guard — an existence check that ran + // after the count check would wrongly reject this no-op. + $this->assertFalse($accessor->remove('does-not-exist')); + $this->assertSame(['a' => 1, 'b' => 2], $accessor->getAll()); + $this->assertSame(['name' => 'Alice', 'a' => 1, 'b' => 2], $object->meta()->rawInput()); + } + /** * A key claimed via the unevaluated accessor stays in `_unevaluatedProperties` even after * a later mutation makes a composition branch claim the same key. The accumulator-rebuild diff --git a/tests/Schema/ArrayPropertyTest/ArrayPropertyWithTransformingFilter.json b/tests/Schema/ArrayPropertyTest/ArrayPropertyWithTransformingFilter.json new file mode 100644 index 00000000..c300e7a4 --- /dev/null +++ b/tests/Schema/ArrayPropertyTest/ArrayPropertyWithTransformingFilter.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Ymd" + } + ] + } + } + } +} diff --git a/tests/Schema/TupleArrayPropertyTest/TupleArrayWithTransformingFilter.json b/tests/Schema/TupleArrayPropertyTest/TupleArrayWithTransformingFilter.json new file mode 100644 index 00000000..f7e04b22 --- /dev/null +++ b/tests/Schema/TupleArrayPropertyTest/TupleArrayWithTransformingFilter.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": [ + { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Ymd" + } + ] + }, + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/SchemaFormWithTransformingFilter.json b/tests/Schema/UnevaluatedItemsValidatorTest/SchemaFormWithTransformingFilter.json new file mode 100644 index 00000000..1e7a9055 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/SchemaFormWithTransformingFilter.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "unevaluatedItems": { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Ymd" + } + ] + } + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleAndUnevaluatedItemsBothWithTransformingFilter.json b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleAndUnevaluatedItemsBothWithTransformingFilter.json new file mode 100644 index 00000000..8641906d --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleAndUnevaluatedItemsBothWithTransformingFilter.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": [ + { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Ymd" + } + ] + } + ], + "unevaluatedItems": { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Y-m-d" + } + ] + } + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/TupleItemsPlusUnevaluatedItemsWithTransformingFilter.json b/tests/Schema/UnevaluatedItemsValidatorTest/TupleItemsPlusUnevaluatedItemsWithTransformingFilter.json new file mode 100644 index 00000000..0e0add71 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/TupleItemsPlusUnevaluatedItemsWithTransformingFilter.json @@ -0,0 +1,26 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "items": [ + { + "type": "string" + } + ] + } + ], + "unevaluatedItems": { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Ymd" + } + ] + } + } + } +} From 693e6fc90c08d73912e0bce957706ddb155c8bc3 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 14 Jul 2026 01:21:43 +0200 Subject: [PATCH 23/51] Credit sibling and branch applicators to unevaluated accumulators Fix the review findings on unevaluatedProperties/unevaluatedItems: - AutoDetectionDraft caches the document dialect per file so a root $schema reaches every subschema; default dialect is now 2020-12 and a nested resource $schema no longer leaks to sibling subschemas. - Direct sibling array applicators credit their evaluated indices: a tuple items (and non-false additionalItems tail) deterministically, a sibling contains by recording matched indices, and items: true is treated as dead code that claims every index. - A composition branch carrying unevaluatedItems without the keyword on the array property now activates the evaluated-index field. - A branch-level unevaluatedProperties: true and a nested-class branch's patternProperties matches are credited to the outer accumulator. - Rename emitted catch variables to $exception (or drop the unused capture) and remove throwaway loop values in generated code. Co-Authored-By: Claude Opus 4.8 --- src/Draft/AutoDetectionDraft.php | 90 ++++++++-- .../Property/CompositionPropertyDecorator.php | 17 ++ .../AbstractUnevaluatedItemsValidator.php | 27 +++ .../Validator/ArrayContainsValidator.php | 17 +- .../UnevaluatedItemsValidatorFactory.php | 15 +- .../UnevaluatedPropertiesPostProcessor.php | 163 +++++++++++++----- .../RemoveAdditionalProperty.phptpl | 9 +- .../Validator/AdditionalProperties.phptpl | 6 +- src/Templates/Validator/ArrayContains.phptpl | 19 +- src/Templates/Validator/ArrayItem.phptpl | 6 +- src/Templates/Validator/ArrayTuple.phptpl | 6 +- src/Templates/Validator/ComposedItem.phptpl | 8 +- .../Validator/ConditionalComposedItem.phptpl | 30 ++-- .../Validator/NoUnevaluatedItems.phptpl | 2 + .../Validator/PatternProperties.phptpl | 6 +- src/Templates/Validator/PropertyNames.phptpl | 6 +- .../Validator/SchemaDependency.phptpl | 4 +- .../Validator/UnevaluatedItems.phptpl | 8 +- .../Validator/UnevaluatedProperties.phptpl | 6 +- src/Utils/ArrayHash.php | 2 +- tests/Draft/DraftTest.php | 78 ++++++++- 21 files changed, 408 insertions(+), 117 deletions(-) diff --git a/src/Draft/AutoDetectionDraft.php b/src/Draft/AutoDetectionDraft.php index 2661b81b..30f0aefe 100644 --- a/src/Draft/AutoDetectionDraft.php +++ b/src/Draft/AutoDetectionDraft.php @@ -6,32 +6,92 @@ use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; +/** + * Resolves the JSON Schema draft to apply to a document from its `$schema` URI. + * + * The `$schema` keyword is a document-level declaration: only the document root carries it, and + * it fixes the dialect for every subschema of that document. `getDraftForSchema()` is queried + * once per (sub)schema, so the resolved draft is cached per source file — the document root is + * always resolved before its subschemas (the root's modifiers run before the ObjectModifier + * descends into properties, and `$ref` targets are processed root-first), so the cached entry a + * subschema reads back is the dialect the root declared. A subschema that declares its own + * `$schema` (a resource root) resolves directly and refreshes the file's cached dialect. + * + * When no recognised `$schema` URI is declared anywhere in a document, the current default + * dialect (Draft 2020-12) applies. Support for additional drafts is added by extending + * self::DRAFT_URIS. + */ class AutoDetectionDraft implements DraftFactoryInterface { - /** URI variants (with and without trailing '#', http and https) identifying a draft 2019-09 schema */ - private const array DRAFT_2019_09_SCHEMA_URIS = [ - 'https://json-schema.org/draft/2019-09/schema', - 'https://json-schema.org/draft/2019-09/schema#', - 'http://json-schema.org/draft/2019-09/schema', - 'http://json-schema.org/draft/2019-09/schema#', + /** + * Draft keyed by its `$schema` identifier — the URI reduced to `/schema`. The four + * canonical variants of each URI (http/https, with/without a trailing '#') all normalise to + * this single identifier via normalizeSchemaUri(), so each draft needs only one entry here. + * + * @var array> + */ + private const array DRAFT_BY_IDENTIFIER = [ + 'draft-07/schema' => Draft_07::class, + 'draft/2019-09/schema' => Draft_2019_09::class, + 'draft/2020-12/schema' => Draft_2020_12::class, ]; - /** @var DraftInterface[] Keyed by draft class name; reused across schemas */ + /** Draft applied when a document declares no recognised `$schema` URI. */ + private const string DEFAULT_DRAFT = Draft_2020_12::class; + + /** @var array, DraftInterface> Keyed by draft class name; reused across schemas */ private array $draftInstances = []; + /** @var array Resolved dialect keyed by source file */ + private array $draftPerFile = []; + public function getDraftForSchema(JsonSchema $jsonSchema): DraftInterface { + $file = $jsonSchema->getFile(); $schemaUri = $jsonSchema->getJson()['$schema'] ?? null; - // Detect draft 2019-09 by its declared $schema URI. Every other case -- - // an absent $schema keyword, the draft-07 URI, or any unrecognised URI -- - // falls back to Draft_07, preserving the previous unconditional behaviour. - // Additional drafts will be detected here when support for them is added - // (e.g. draft-04, draft 2020-12). - if (in_array($schemaUri, self::DRAFT_2019_09_SCHEMA_URIS, true)) { - return $this->draftInstances[Draft_2019_09::class] ??= new Draft_2019_09(); + // A (sub)schema that declares $schema itself is resolved to that dialect directly. Only + // the first declaration seen for a file — the document root, always resolved before its + // subschemas — seeds the file-level dialect that subschemas inherit. A later declaration + // (e.g. a nested resource root with its own $schema) never overwrites that seed, so its + // dialect cannot leak into the root's sibling subschemas. + if ($schemaUri !== null) { + $draftClass = self::DRAFT_BY_IDENTIFIER[$this->normalizeSchemaUri($schemaUri)] ?? null; + + if ($draftClass !== null) { + $draft = $this->draft($draftClass); + $this->draftPerFile[$file] ??= $draft; + + return $draft; + } } - return $this->draftInstances[Draft_07::class] ??= new Draft_07(); + // Subschemas do not repeat $schema; they inherit the dialect resolved for the document + // root. Fall back to the default dialect when the document declared no recognised $schema + // anywhere — this also covers an unrecognised $schema URI. (A nested resource root's own + // subschemas likewise inherit the document dialect rather than the nested $schema; that + // embedded-resource case is not modelled.) + return $this->draftPerFile[$file] ??= $this->draft(self::DEFAULT_DRAFT); + } + + /** + * Reduces a `$schema` URI to its draft identifier by dropping the json-schema.org host in + * either scheme and any trailing '#', so all four canonical variants collapse to one key. + * A non-matching URI is returned unchanged (minus a trailing '#') and simply misses the map. + */ + private function normalizeSchemaUri(string $schemaUri): string + { + return rtrim( + str_replace(['https://json-schema.org/', 'http://json-schema.org/'], '', $schemaUri), + '#', + ); + } + + /** + * @param class-string $draftClass + */ + private function draft(string $draftClass): DraftInterface + { + return $this->draftInstances[$draftClass] ??= new $draftClass(); } } diff --git a/src/Model/Property/CompositionPropertyDecorator.php b/src/Model/Property/CompositionPropertyDecorator.php index 640ff67e..665a05e2 100644 --- a/src/Model/Property/CompositionPropertyDecorator.php +++ b/src/Model/Property/CompositionPropertyDecorator.php @@ -146,6 +146,23 @@ public function branchHasNonFalseAdditionalProperties(): bool && $branchJson['additionalProperties'] !== false; } + /** + * Returns true when a successful branch is treated as evaluating every model key. That holds + * when the branch declares a non-false `additionalProperties` (claims every extra key) or an + * `unevaluatedProperties: true` (claims every key the branch did not otherwise evaluate). + * Either way the whole instance is covered, so the branch's evaluation slot becomes an + * all-keys-evaluated marker rather than an explicit name list or nested-instance query. + * + * `unevaluatedProperties: {schema}` is deliberately excluded: it claims only the remaining + * keys whose values validate against the subschema, which is instance-dependent and cannot be + * reduced to an all-keys marker at generation time. + */ + public function branchClaimsAllKeys(): bool + { + return $this->branchHasNonFalseAdditionalProperties() + || ($this->jsonSchema->getJson()['unevaluatedProperties'] ?? null) === true; + } + /** * True when the branch declares `items` as a schema object (not a tuple list, not a * boolean). A schema-form `items` claims every index in the validated array. diff --git a/src/Model/Validator/AbstractUnevaluatedItemsValidator.php b/src/Model/Validator/AbstractUnevaluatedItemsValidator.php index 6fc5cfd3..2eb866ed 100644 --- a/src/Model/Validator/AbstractUnevaluatedItemsValidator.php +++ b/src/Model/Validator/AbstractUnevaluatedItemsValidator.php @@ -35,18 +35,45 @@ public function __construct( ) { $this->parentProperty = $property; + [$siblingTupleItemsCount, $siblingCoversTail] = $this->siblingItemsCoverage( + $property->getJsonSchema()->getJson(), + ); + parent::__construct( $property, $templatePath, $extraTemplateValues + [ 'arrayPropertyName' => $property->getName(), 'compositionSlotKeys' => '[]', + 'siblingTupleItemsCount' => $siblingTupleItemsCount, + 'siblingCoversTail' => $siblingCoversTail ? 'true' : 'false', ], $exceptionClass, $exceptionParams, ); } + /** + * Positional index coverage from a sibling `items` tuple and `additionalItems` on the same + * property: a tuple `items` evaluates indices [0, count); a non-false `additionalItems` then + * evaluates every index past the tuple. Every other `items` shape is suppressed as dead code + * by UnevaluatedItemsValidatorFactory, so it contributes no coverage here. + * + * @return array{0: int, 1: bool} [tuple length, whether additionalItems covers the tail] + */ + private function siblingItemsCoverage(array $json): array + { + $items = $json['items'] ?? null; + + if (!is_array($items) || $items === [] || !array_is_list($items)) { + return [0, false]; + } + + $coversTail = array_key_exists('additionalItems', $json) && $json['additionalItems'] !== false; + + return [count($items), $coversTail]; + } + public function getCheck(): string { $slotKeys = []; diff --git a/src/Model/Validator/ArrayContainsValidator.php b/src/Model/Validator/ArrayContainsValidator.php index 17f46c61..1e538599 100644 --- a/src/Model/Validator/ArrayContainsValidator.php +++ b/src/Model/Validator/ArrayContainsValidator.php @@ -35,6 +35,8 @@ public function __construct( 'countMatches' => $countMatches, 'allowNoMatch' => $allowNoMatch, 'trackBranchMatches' => false, + 'trackEvaluatedItems' => false, + 'trackEvaluatedItemsProperty' => '', 'viewHelper' => new RenderHelper($generatorConfiguration), 'generatorConfiguration' => $generatorConfiguration, ], @@ -43,7 +45,7 @@ public function __construct( } /** - * Enable the per-index match map: the rendered IIFE captures `$_branchContainsMatches` + * Enable the per-index match map: the rendered IIFE captures `$branchContainsMatches` * by reference from its surrounding scope and writes `true` at each matched index. The * composition template in a tracked branch sets up the accumulator and unions it into * the branch's evaluated index set on success. @@ -53,6 +55,19 @@ public function setTrackBranchMatches(bool $trackBranchMatches): void $this->templateValues['trackBranchMatches'] = $trackBranchMatches; } + /** + * Enable direct-sibling index crediting: the rendered IIFE writes each matched index into + * `$this->_evaluatedItemIndices[]` so a sibling unevaluatedItems validator on the + * same property sees the matched indices as evaluated. Used when the `contains` keyword sits + * directly on an array property (not inside a composition branch). The property name is the + * validator's own bound property — the same name the sibling unevaluatedItems check keys on. + */ + public function setTrackEvaluatedItems(): void + { + $this->templateValues['trackEvaluatedItems'] = true; + $this->templateValues['trackEvaluatedItemsProperty'] = $this->property->getName(); + } + public function getValidatorSetUp(): string { return $this->templateValues['countMatches'] ? ' diff --git a/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php index e7414db0..f40ef3fb 100644 --- a/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php +++ b/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php @@ -105,9 +105,18 @@ private function isDeadCode( return true; } - $isTupleItems = is_array($items) - && $items !== [] - && array_keys($items) === range(0, count($items) - 1); + if ($items === true) { + $this->warn( + $schemaProcessor, + $schema, + $property, + "sibling items: true already claims every index", + ); + + return true; + } + + $isTupleItems = is_array($items) && $items !== [] && array_is_list($items); if ($isTupleItems) { if (($json['additionalItems'] ?? null) === false) { diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php index 12cea24e..dc9a4242 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -142,16 +142,10 @@ private function activateSchemaLevelTracking(Schema $schema): void */ private function activateArrayPropertyTracking(Schema $schema): void { - $unevaluatedItemsPresent = false; + $evaluatedItemIndicesNeeded = false; $compositionActivated = false; foreach ($schema->getProperties() as $schemaProperty) { - $propertyJson = $schemaProperty->getJsonSchema()->getJson(); - - if (!array_key_exists('unevaluatedItems', $propertyJson)) { - continue; - } - // The unevaluatedItems factory is registered on the `array` type only, so a // property whose type cannot hold an array never produces an unevaluatedItems // validator at runtime. Activating compositions in that case would write @@ -161,7 +155,29 @@ private function activateArrayPropertyTracking(Schema $schema): void continue; } - $unevaluatedItemsPresent = true; + $propertyJson = $schemaProperty->getJsonSchema()->getJson(); + $hasDirectUnevaluatedItems = array_key_exists('unevaluatedItems', $propertyJson); + // A composition branch may carry unevaluatedItems even when the array property itself + // does not (e.g. allOf: [{unevaluatedItems: {schema}}]). The branch's validator still + // needs the _evaluatedItemIndices field declared on the class. + $hasBranchUnevaluatedItems = $this->propertyHasBranchUnevaluatedItems($schemaProperty); + + if (!$hasDirectUnevaluatedItems && !$hasBranchUnevaluatedItems) { + continue; + } + + $evaluatedItemIndicesNeeded = true; + + // Composition activation and sibling crediting are only relevant when the property + // itself carries an unevaluatedItems check that reads those annotations. A branch-only + // unevaluatedItems validates within its own branch and needs no outer crediting. + if (!$hasDirectUnevaluatedItems) { + continue; + } + + // A direct-sibling `contains` credits its matched indices to the property's evaluated + // set so the sibling unevaluatedItems check sees them as evaluated. + $this->enableDirectSiblingContainsTracking($schemaProperty); $this->slotKeyCounter = 0; $this->activatedCompositions = []; @@ -189,7 +205,7 @@ private function activateArrayPropertyTracking(Schema $schema): void ); } - if ($unevaluatedItemsPresent) { + if ($evaluatedItemIndicesNeeded) { // Per-array-property index map of indices the property's UnevaluatedItems validator // successfully evaluated, shaped as [propertyName => [index => true]]. Inner and outer // UnevaluatedItems validators share the same instance field — there are no nested @@ -207,6 +223,41 @@ private function activateArrayPropertyTracking(Schema $schema): void } } + /** + * True when any composition validator directly on the property carries a branch declaring + * `unevaluatedItems`. Such a branch's validator writes and reads `_evaluatedItemIndices`, so + * the field must be declared even though the array property itself has no unevaluatedItems. + */ + private function propertyHasBranchUnevaluatedItems(PropertyInterface $property): bool + { + foreach ($property->getOrderedValidators() as $validator) { + if (!$validator instanceof AbstractComposedPropertyValidator) { + continue; + } + + foreach ($validator->getComposedProperties() as $composedProperty) { + if (array_key_exists('unevaluatedItems', $composedProperty->getBranchSchema()->getJson())) { + return true; + } + } + } + + return false; + } + + /** + * Enable direct-sibling index crediting on any `contains` validator sitting directly on the + * property, so a sibling unevaluatedItems check sees the matched indices as evaluated. + */ + private function enableDirectSiblingContainsTracking(PropertyInterface $property): void + { + foreach ($property->getOrderedValidators() as $validator) { + if ($validator instanceof ArrayContainsValidator) { + $validator->setTrackEvaluatedItems(); + } + } + } + /** * Enable evaluation tracking on a property-level composition validator on an array * property and recurse into its branches. The slot-key counter on the post-processor @@ -316,9 +367,11 @@ private function addGetEvaluatedPropertiesToNestedBranchSchemas(Schema $schema): /** * Builds a MethodInterface that emits `_getEvaluatedProperties()` for the given nested - * schema. The method returns the union of declared property names present in the - * instance's raw model data and the keys recorded in `_evaluatedPropertyKeys` (populated - * by the nested schema's own unevaluatedProperties validator). + * schema. The method returns the union of declared property names present in the instance's + * raw model data, the keys matched by the schema's own `patternProperties`, and the keys + * recorded in `_evaluatedPropertyKeys` (populated by the nested schema's own + * unevaluatedProperties validator). Together these are the keys the branch evaluated, so an + * enclosing unevaluatedProperties check must credit them. */ private function buildGetEvaluatedPropertiesMethod(Schema $nestedSchema): MethodInterface { @@ -348,12 +401,18 @@ public function _getEvaluatedProperties(): array $evaluated[$propName] = true; } } + // Keys matched by this schema\'s own patternProperties (with passing + // values) are evaluated too, so an enclosing schema must see them. Only the + // keys matter (the result is array_keys($evaluated)), so union the maps. + if (property_exists($this, "_patternProperties")) { + foreach ($this->_patternProperties as $patternMatches) { + $evaluated += $patternMatches; + } + } // Keys evaluated by this schema\'s own unevaluatedProperties: {schema} // validator are tracked so an enclosing schema can see them. if (property_exists($this, "_evaluatedPropertyKeys")) { - foreach ($this->_evaluatedPropertyKeys as $propName => $_) { - $evaluated[$propName] = true; - } + $evaluated += $this->_evaluatedPropertyKeys; } return array_keys($evaluated); }', @@ -387,35 +446,15 @@ private function needsActivation(Schema $schema, array &$seen): bool return $seen[$schemaKey] = true; } - // Check each composition branch: both the branch-level JSON and any nested schema it produces. - foreach ($schema->getBaseValidators() as $baseValidator) { - if (!$baseValidator instanceof AbstractComposedPropertyValidator) { - continue; - } - - foreach ($baseValidator->getComposedProperties() as $composedProperty) { - $branchJson = $composedProperty->getBranchSchema()->getJson(); - - if ( - array_key_exists('unevaluatedProperties', $branchJson) - || array_key_exists('unevaluatedItems', $branchJson) - ) { - return $seen[$schemaKey] = true; - } - - $nestedSchema = $composedProperty->getNestedSchema(); - - if ($nestedSchema !== null && $this->needsActivation($nestedSchema, $seen)) { - return $seen[$schemaKey] = true; - } - } + // Check each schema-level composition: both the branch-level JSON and any nested schema. + if ($this->compositionValidatorsNeedActivation($schema->getBaseValidators(), $seen)) { + return $seen[$schemaKey] = true; } - // Check property-level nested schemas (e.g. an object-typed property with its own composition). - // Array properties never carry a nested schema — their unevaluatedItems lives in the - // property's own JSON, so check that before the getNestedSchema() recursion. Without - // this, a schema like {type: object, properties: {tags: {type: array, unevaluatedItems: false}}} - // would miss activation entirely. + // Check each property: its own JSON, its nested schema, and any composition sitting + // directly on it. Array properties never carry a nested schema — their unevaluatedItems + // lives in the property's own JSON or in a composition branch on the property (e.g. + // {tags: {type: array, allOf: [{unevaluatedItems: {schema}}]}}), so both must be checked. foreach ($schema->getProperties() as $schemaProperty) { $propertyJson = $schemaProperty->getJsonSchema()->getJson(); @@ -431,8 +470,48 @@ private function needsActivation(Schema $schema, array &$seen): bool if ($nestedSchema !== null && $this->needsActivation($nestedSchema, $seen)) { return $seen[$schemaKey] = true; } + + if ($this->compositionValidatorsNeedActivation($schemaProperty->getOrderedValidators(), $seen)) { + return $seen[$schemaKey] = true; + } } return $seen[$schemaKey] = false; } + + /** + * True when any composition validator in the given list carries a branch that declares an + * unevaluated keyword — either in the branch-level JSON or in a nested schema the branch + * produces. Shared by the schema-level (base validators) and property-level checks. + * + * @param iterable $validators + * @param array $seen + */ + private function compositionValidatorsNeedActivation(iterable $validators, array &$seen): bool + { + foreach ($validators as $validator) { + if (!$validator instanceof AbstractComposedPropertyValidator) { + continue; + } + + foreach ($validator->getComposedProperties() as $composedProperty) { + $branchJson = $composedProperty->getBranchSchema()->getJson(); + + if ( + array_key_exists('unevaluatedProperties', $branchJson) + || array_key_exists('unevaluatedItems', $branchJson) + ) { + return true; + } + + $nestedSchema = $composedProperty->getNestedSchema(); + + if ($nestedSchema !== null && $this->needsActivation($nestedSchema, $seen)) { + return true; + } + } + } + + return false; + } } diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl index 57fa7ce5..678b10c1 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl @@ -7,8 +7,8 @@ private function _removeAdditionalProperty(string $key): bool $inPatternProperties = false; if (isset($this->_patternProperties)) { - foreach ($this->_patternProperties as $patternHash => $_) { - if (isset($this->_patternProperties[$patternHash][$key])) { + foreach ($this->_patternProperties as $patternEntries) { + if (isset($patternEntries[$key])) { $inPatternProperties = true; break; } @@ -86,9 +86,10 @@ private function _removeAdditionalProperty(string $key): bool {% endif %} if ($inPatternProperties) { - foreach ($this->_patternProperties as $patternHash => $_) { - unset($this->_patternProperties[$patternHash][$key]); + foreach ($this->_patternProperties as &$patternEntries) { + unset($patternEntries[$key]); } + unset($patternEntries); } if ($inAdditionalProperties) { diff --git a/src/Templates/Validator/AdditionalProperties.phptpl b/src/Templates/Validator/AdditionalProperties.phptpl index f3326bf3..18ed417d 100644 --- a/src/Templates/Validator/AdditionalProperties.phptpl +++ b/src/Templates/Validator/AdditionalProperties.phptpl @@ -37,11 +37,11 @@ {% if collectAdditionalProperties %} $this->_additionalProperties[$propertyKey] = $value; {% endif %} - } catch (\Exception $e) { + } catch (\Exception $exception) { // collect all errors concerning invalid additional properties isset($invalidProperties[$propertyKey]) - ? $invalidProperties[$propertyKey][] = $e - : $invalidProperties[$propertyKey] = [$e]; + ? $invalidProperties[$propertyKey][] = $exception + : $invalidProperties[$propertyKey] = [$exception]; } } diff --git a/src/Templates/Validator/ArrayContains.phptpl b/src/Templates/Validator/ArrayContains.phptpl index e083c6f3..3c3c6eca 100644 --- a/src/Templates/Validator/ArrayContains.phptpl +++ b/src/Templates/Validator/ArrayContains.phptpl @@ -7,6 +7,10 @@ is_array($value) && (function (&$items){% if countMatches or trackBranchMatches $originalErrorRegistry = $this->_errorRegistry; {% endif %} + {% if trackEvaluatedItems %} + $branchContainsMatches = []; + {% endif %} + foreach ($items as $index => &$value) { try { {% if generatorConfiguration.collectErrors() %} @@ -27,18 +31,18 @@ is_array($value) && (function (&$items){% if countMatches or trackBranchMatches $this->_errorRegistry = $originalErrorRegistry; {% endif %} - {% if trackBranchMatches %} + {% if trackBranchMatches or trackEvaluatedItems %} $branchContainsMatches[$index] = true; {% endif %} {% if countMatches %} $containsMatches++; {% endif %} - {% if not countMatches and not trackBranchMatches %} + {% if not countMatches and not trackBranchMatches and not trackEvaluatedItems %} // one matched item is enough; no need to keep iterating return false; {% endif %} - } catch (\Exception $e) { + } catch (\Exception) { continue; } } @@ -47,10 +51,17 @@ is_array($value) && (function (&$items){% if countMatches or trackBranchMatches $this->_errorRegistry = $originalErrorRegistry; {% endif %} + {% if trackEvaluatedItems %} + // Credit every matched index to the property's evaluated-index set so a sibling + // unevaluatedItems validator sees those indices as evaluated. + $this->_evaluatedItemIndices['{{ trackEvaluatedItemsProperty }}'] = + ($this->_evaluatedItemIndices['{{ trackEvaluatedItemsProperty }}'] ?? []) + $branchContainsMatches; + {% endif %} + return {% if countMatches %} {% if allowNoMatch %}false{% else %}$containsMatches === 0{% endif %} {% else %} - {% if trackBranchMatches %} + {% if trackBranchMatches or trackEvaluatedItems %} empty($branchContainsMatches) {% else %} true diff --git a/src/Templates/Validator/ArrayItem.phptpl b/src/Templates/Validator/ArrayItem.phptpl index cdbea591..e154abb0 100644 --- a/src/Templates/Validator/ArrayItem.phptpl +++ b/src/Templates/Validator/ArrayItem.phptpl @@ -20,11 +20,11 @@ is_array($value) && (function (&$items) use (&$invalidItems{{ suffix }}, $modelD $invalidItems{{ suffix }}[$index] = $this->_errorRegistry->getErrors(); } {% endif %} - } catch (\Exception $e) { + } catch (\Exception $exception) { // collect all errors concerning invalid items isset($invalidItems{{ suffix }}[$index]) - ? $invalidItems{{ suffix }}[$index][] = $e - : $invalidItems{{ suffix }}[$index] = [$e]; + ? $invalidItems{{ suffix }}[$index][] = $exception + : $invalidItems{{ suffix }}[$index] = [$exception]; } } diff --git a/src/Templates/Validator/ArrayTuple.phptpl b/src/Templates/Validator/ArrayTuple.phptpl index d6ce49ea..488b57d1 100644 --- a/src/Templates/Validator/ArrayTuple.phptpl +++ b/src/Templates/Validator/ArrayTuple.phptpl @@ -27,11 +27,11 @@ is_array($value) && (function (&$items) use (&$invalidTuples, $modelData) { $invalidTuples[$index] = $this->_errorRegistry->getErrors(); } {% endif %} - } catch (\Exception $e) { + } catch (\Exception $exception) { // collect all errors concerning invalid tuples isset($invalidTuples[$index]) - ? $invalidTuples[$index][] = $e - : $invalidTuples[$index] = [$e]; + ? $invalidTuples[$index][] = $exception + : $invalidTuples[$index] = [$exception]; } {% endforeach %} diff --git a/src/Templates/Validator/ComposedItem.phptpl b/src/Templates/Validator/ComposedItem.phptpl index e1428fb5..a91f1056 100644 --- a/src/Templates/Validator/ComposedItem.phptpl +++ b/src/Templates/Validator/ComposedItem.phptpl @@ -157,13 +157,13 @@ {% endif %} {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} if ($shouldTrack) { - {% if compositionProperty.branchHasNonFalseAdditionalProperties() %} + {% if compositionProperty.branchClaimsAllKeys() %} $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = true; {% endif %} - {% if not compositionProperty.branchHasNonFalseAdditionalProperties() and compositionProperty.getNestedSchema() %} + {% if not compositionProperty.branchClaimsAllKeys() and compositionProperty.getNestedSchema() %} $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = is_object($value) ? $value : null; {% endif %} - {% if not compositionProperty.branchHasNonFalseAdditionalProperties() and not compositionProperty.getNestedSchema() %} + {% if not compositionProperty.branchClaimsAllKeys() and not compositionProperty.getNestedSchema() %} $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = $this->collectEvaluatedProperties( $modelData, {{ compositionProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, @@ -172,7 +172,7 @@ {% endif %} } {% endif %} - } catch (\Exception $e) { + } catch (\Exception) { {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) and not generatorConfiguration.collectErrors() %} if (isset($validatorIndex)) { $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex] = false; diff --git a/src/Templates/Validator/ConditionalComposedItem.phptpl b/src/Templates/Validator/ConditionalComposedItem.phptpl index 20c68f00..c557a5a2 100644 --- a/src/Templates/Validator/ConditionalComposedItem.phptpl +++ b/src/Templates/Validator/ConditionalComposedItem.phptpl @@ -43,8 +43,8 @@ {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and ifProperty.getNestedSchema() %} $capturedIfInstance = is_object($value) ? $value : null; {% endif %} - } catch (\Exception $e) { - $ifException = $e; + } catch (\Exception $exception) { + $ifException = $exception; } $value = $originalModelData; @@ -77,13 +77,13 @@ if ($ifException) { $this->_compositionEvaluations[$validatorIndex][0] = null; } else { - {% if ifProperty.branchHasNonFalseAdditionalProperties() %} + {% if ifProperty.branchClaimsAllKeys() %} $this->_compositionEvaluations[$validatorIndex][0] = true; {% endif %} - {% if not ifProperty.branchHasNonFalseAdditionalProperties() and ifProperty.getNestedSchema() %} + {% if not ifProperty.branchClaimsAllKeys() and ifProperty.getNestedSchema() %} $this->_compositionEvaluations[$validatorIndex][0] = $capturedIfInstance; {% endif %} - {% if not ifProperty.branchHasNonFalseAdditionalProperties() and not ifProperty.getNestedSchema() %} + {% if not ifProperty.branchClaimsAllKeys() and not ifProperty.getNestedSchema() %} $this->_compositionEvaluations[$validatorIndex][0] = $this->collectEvaluatedProperties( $modelData, {{ ifProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, @@ -123,8 +123,8 @@ {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and thenProperty.getNestedSchema() %} $capturedThenInstance = is_object($value) ? $value : null; {% endif %} - } catch (\Exception $e) { - $thenException = $e; + } catch (\Exception $exception) { + $thenException = $exception; } {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and thenProperty.branchIsArrayKind() %} @@ -155,13 +155,13 @@ if ($thenException) { $this->_compositionEvaluations[$validatorIndex][1] = null; } else { - {% if thenProperty.branchHasNonFalseAdditionalProperties() %} + {% if thenProperty.branchClaimsAllKeys() %} $this->_compositionEvaluations[$validatorIndex][1] = true; {% endif %} - {% if not thenProperty.branchHasNonFalseAdditionalProperties() and thenProperty.getNestedSchema() %} + {% if not thenProperty.branchClaimsAllKeys() and thenProperty.getNestedSchema() %} $this->_compositionEvaluations[$validatorIndex][1] = $capturedThenInstance; {% endif %} - {% if not thenProperty.branchHasNonFalseAdditionalProperties() and not thenProperty.getNestedSchema() %} + {% if not thenProperty.branchClaimsAllKeys() and not thenProperty.getNestedSchema() %} $this->_compositionEvaluations[$validatorIndex][1] = $this->collectEvaluatedProperties( $modelData, {{ thenProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, @@ -204,8 +204,8 @@ {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and elseProperty.getNestedSchema() %} $capturedElseInstance = is_object($value) ? $value : null; {% endif %} - } catch (\Exception $e) { - $elseException = $e; + } catch (\Exception $exception) { + $elseException = $exception; } {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and elseProperty.branchIsArrayKind() %} @@ -236,13 +236,13 @@ if ($elseException) { $this->_compositionEvaluations[$validatorIndex][2] = null; } else { - {% if elseProperty.branchHasNonFalseAdditionalProperties() %} + {% if elseProperty.branchClaimsAllKeys() %} $this->_compositionEvaluations[$validatorIndex][2] = true; {% endif %} - {% if not elseProperty.branchHasNonFalseAdditionalProperties() and elseProperty.getNestedSchema() %} + {% if not elseProperty.branchClaimsAllKeys() and elseProperty.getNestedSchema() %} $this->_compositionEvaluations[$validatorIndex][2] = $capturedElseInstance; {% endif %} - {% if not elseProperty.branchHasNonFalseAdditionalProperties() and not elseProperty.getNestedSchema() %} + {% if not elseProperty.branchClaimsAllKeys() and not elseProperty.getNestedSchema() %} $this->_compositionEvaluations[$validatorIndex][2] = $this->collectEvaluatedProperties( $modelData, {{ elseProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, diff --git a/src/Templates/Validator/NoUnevaluatedItems.phptpl b/src/Templates/Validator/NoUnevaluatedItems.phptpl index b8a895c4..496981fc 100644 --- a/src/Templates/Validator/NoUnevaluatedItems.phptpl +++ b/src/Templates/Validator/NoUnevaluatedItems.phptpl @@ -2,4 +2,6 @@ is_array($value) && ($unevaluatedItems = $this->collectUnevaluatedIndices( $value, '{{ arrayPropertyName }}', {{ compositionSlotKeys }}, + {{ siblingTupleItemsCount }}, + {{ siblingCoversTail }}, )) diff --git a/src/Templates/Validator/PatternProperties.phptpl b/src/Templates/Validator/PatternProperties.phptpl index 5efc7af4..39d06943 100644 --- a/src/Templates/Validator/PatternProperties.phptpl +++ b/src/Templates/Validator/PatternProperties.phptpl @@ -31,11 +31,11 @@ if (!isset($this->_patternPropertiesMap[$propertyKey])) { $this->_patternProperties['{{ patternHash }}'][$propertyKey] = $value; } - } catch (\Exception $e) { + } catch (\Exception $exception) { // collect all errors concerning invalid pattern properties isset($invalidProperties[$propertyKey]) - ? $invalidProperties[$propertyKey][] = $e - : $invalidProperties[$propertyKey] = [$e]; + ? $invalidProperties[$propertyKey][] = $exception + : $invalidProperties[$propertyKey] = [$exception]; } } diff --git a/src/Templates/Validator/PropertyNames.phptpl b/src/Templates/Validator/PropertyNames.phptpl index fb63a6f8..8a0a3ec8 100644 --- a/src/Templates/Validator/PropertyNames.phptpl +++ b/src/Templates/Validator/PropertyNames.phptpl @@ -21,11 +21,11 @@ $invalidProperties[$value] = $this->_errorRegistry->getErrors(); } {% endif %} - } catch (\Exception $e) { + } catch (\Exception $exception) { // collect all errors concerning invalid property names isset($invalidProperties[$value]) - ? $invalidProperties[$value][] = $e - : $invalidProperties[$value] = [$e]; + ? $invalidProperties[$value][] = $exception + : $invalidProperties[$value] = [$exception]; } } diff --git a/src/Templates/Validator/SchemaDependency.phptpl b/src/Templates/Validator/SchemaDependency.phptpl index 33d4c6fb..2f70dd42 100644 --- a/src/Templates/Validator/SchemaDependency.phptpl +++ b/src/Templates/Validator/SchemaDependency.phptpl @@ -16,8 +16,8 @@ array_key_exists('{{ property.getName() }}', $modelData) && (function () use ($m $this->_errorRegistry = $originalErrorRegistry; {% else %} - } catch (\Exception $e) { - $dependencyException = $e; + } catch (\Exception $exception) { + $dependencyException = $exception; } {% endif %} diff --git a/src/Templates/Validator/UnevaluatedItems.phptpl b/src/Templates/Validator/UnevaluatedItems.phptpl index d6b15281..0111ca63 100644 --- a/src/Templates/Validator/UnevaluatedItems.phptpl +++ b/src/Templates/Validator/UnevaluatedItems.phptpl @@ -8,6 +8,8 @@ is_array($value) && (function (&$items) use (&$invalidItems, $modelData): bool { $items, '{{ arrayPropertyName }}', {{ compositionSlotKeys }}, + {{ siblingTupleItemsCount }}, + {{ siblingCoversTail }}, ); foreach ($unevaluatedIndices as $index) { @@ -34,10 +36,10 @@ is_array($value) && (function (&$items) use (&$invalidItems, $modelData): bool { // The index validated against the unevaluatedItems subschema — record it so an // enclosing schema's unevaluatedItems accumulator can credit it. $this->_evaluatedItemIndices['{{ arrayPropertyName }}'][$index] = true; - } catch (\Exception $e) { + } catch (\Exception $exception) { isset($invalidItems[$index]) - ? $invalidItems[$index][] = $e - : $invalidItems[$index] = [$e]; + ? $invalidItems[$index][] = $exception + : $invalidItems[$index] = [$exception]; } } diff --git a/src/Templates/Validator/UnevaluatedProperties.phptpl b/src/Templates/Validator/UnevaluatedProperties.phptpl index 901626b5..8ee09cb9 100644 --- a/src/Templates/Validator/UnevaluatedProperties.phptpl +++ b/src/Templates/Validator/UnevaluatedProperties.phptpl @@ -43,10 +43,10 @@ {% if collectUnevaluatedProperties %} $this->_unevaluatedProperties[$propertyKey] = $value; {% endif %} - } catch (\Exception $e) { + } catch (\Exception $exception) { isset($invalidProperties[$propertyKey]) - ? $invalidProperties[$propertyKey][] = $e - : $invalidProperties[$propertyKey] = [$e]; + ? $invalidProperties[$propertyKey][] = $exception + : $invalidProperties[$propertyKey] = [$exception]; } } diff --git a/src/Utils/ArrayHash.php b/src/Utils/ArrayHash.php index d41d583c..3c8ddcfc 100644 --- a/src/Utils/ArrayHash.php +++ b/src/Utils/ArrayHash.php @@ -9,7 +9,7 @@ class ArrayHash public static function hash(array $array, array $relevantFields = []): string { if ($relevantFields) { - foreach ($array as $key => $_) { + foreach (array_keys($array) as $key) { if (!in_array($key, $relevantFields)) { unset($array[$key]); } diff --git a/tests/Draft/DraftTest.php b/tests/Draft/DraftTest.php index 8bad2886..1726db4a 100644 --- a/tests/Draft/DraftTest.php +++ b/tests/Draft/DraftTest.php @@ -7,6 +7,7 @@ use PHPModelGenerator\Draft\AutoDetectionDraft; use PHPModelGenerator\Draft\Draft_07; use PHPModelGenerator\Draft\Draft_2019_09; +use PHPModelGenerator\Draft\Draft_2020_12; use PHPModelGenerator\Draft\Element\Type; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Exception\String\MinLengthException; @@ -157,18 +158,18 @@ public function testAutoDetectionReturnsDraft07ForDraft07SchemaKeyword(): void $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); } - public function testAutoDetectionFallsBackToDraft07WhenSchemaKeywordAbsent(): void + public function testAutoDetectionFallsBackToDraft202012WhenSchemaKeywordAbsent(): void { $jsonSchema = new JsonSchema('test.json', ['type' => 'object']); - $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + $this->assertInstanceOf(Draft_2020_12::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); } - public function testAutoDetectionFallsBackToDraft07ForUnrecognisedSchemaKeyword(): void + public function testAutoDetectionFallsBackToDraft202012ForUnrecognisedSchemaKeyword(): void { $jsonSchema = new JsonSchema('test.json', ['$schema' => 'https://example.com/custom-schema']); - $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + $this->assertInstanceOf(Draft_2020_12::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); } /** @return array */ @@ -195,7 +196,7 @@ public function testAutoDetectionReusesCachedDraft07Instance(): void $autoDetectionDraft = new AutoDetectionDraft(); $firstSchema = new JsonSchema('first.json', ['$schema' => 'http://json-schema.org/draft-07/schema#']); - $secondSchema = new JsonSchema('second.json', ['type' => 'object']); + $secondSchema = new JsonSchema('second.json', ['$schema' => 'https://json-schema.org/draft-07/schema']); $this->assertSame( $autoDetectionDraft->getDraftForSchema($firstSchema), @@ -203,6 +204,73 @@ public function testAutoDetectionReusesCachedDraft07Instance(): void ); } + /** @return array */ + public static function draft202012SchemaUriProvider(): array + { + return [ + 'https without trailing hash' => ['https://json-schema.org/draft/2020-12/schema'], + 'https with trailing hash' => ['https://json-schema.org/draft/2020-12/schema#'], + 'http without trailing hash' => ['http://json-schema.org/draft/2020-12/schema'], + 'http with trailing hash' => ['http://json-schema.org/draft/2020-12/schema#'], + ]; + } + + #[DataProvider('draft202012SchemaUriProvider')] + public function testAutoDetectionReturnsDraft202012ForDraft202012SchemaKeyword(string $schemaUri): void + { + $jsonSchema = new JsonSchema('test.json', ['$schema' => $schemaUri]); + + $this->assertInstanceOf(Draft_2020_12::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + } + + /** + * The dialect declared at the document root governs every subschema of the same source file: + * a subschema (queried after the root, and carrying no `$schema` of its own) resolves to the + * draft the root declared rather than falling back to the default. + */ + public function testAutoDetectionAppliesRootDialectToSubschemasOfTheSameFile(): void + { + $autoDetectionDraft = new AutoDetectionDraft(); + + $rootSchema = new JsonSchema('document.json', [ + '$schema' => 'https://json-schema.org/draft/2019-09/schema', + 'type' => 'object', + ]); + $subSchema = new JsonSchema('document.json', ['type' => 'object'], '/properties/child'); + + // Root is resolved first, seeding the per-file dialect. + $this->assertInstanceOf(Draft_2019_09::class, $autoDetectionDraft->getDraftForSchema($rootSchema)); + $this->assertInstanceOf(Draft_2019_09::class, $autoDetectionDraft->getDraftForSchema($subSchema)); + } + + /** + * A nested subschema that declares its own `$schema` resolves to that dialect for itself, but + * must not overwrite the document dialect seeded by the root: a sibling subschema processed + * afterwards still inherits the root's dialect, not the nested declaration's. + */ + public function testNestedSchemaDeclarationDoesNotLeakIntoSiblingSubschemas(): void + { + $autoDetectionDraft = new AutoDetectionDraft(); + + $rootSchema = new JsonSchema('document.json', [ + '$schema' => 'https://json-schema.org/draft/2019-09/schema', + 'type' => 'object', + ]); + $nestedResourceRoot = new JsonSchema( + 'document.json', + ['$schema' => 'http://json-schema.org/draft-07/schema#', 'type' => 'object'], + '/properties/child', + ); + $sibling = new JsonSchema('document.json', ['type' => 'object'], '/properties/other'); + + // Document root seeds the file dialect. + $this->assertInstanceOf(Draft_2019_09::class, $autoDetectionDraft->getDraftForSchema($rootSchema)); + // The nested resource root resolves to its own declared dialect. + $this->assertInstanceOf(Draft_07::class, $autoDetectionDraft->getDraftForSchema($nestedResourceRoot)); + // The sibling still inherits the root's dialect — the nested declaration did not leak. + $this->assertInstanceOf(Draft_2019_09::class, $autoDetectionDraft->getDraftForSchema($sibling)); + } + public function testAutoDetectionReusesCachedDraft201909Instance(): void { $autoDetectionDraft = new AutoDetectionDraft(); From 97ba6db44be15712505f6e405e5cf6e52bc3f556 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 23 Jul 2026 23:48:18 +0200 Subject: [PATCH 24/51] Apply type-specific applicators to untyped subschemas An untyped property resolved to type 'any', and PropertyFactory ran only the 'any' Draft entry. Every type-specific applicator -- properties, patternProperties, additionalProperties, unevaluatedProperties, minLength, minItems, ... -- was therefore silently dropped. JSON Schema applicators are not gated on a type declaration, so they must apply whenever the instance is of the relevant type. Route the 'any' case to createUntypedProperty, which wires three layers onto a single permissive property: - Object applicators generate the nested class (type: object is forced only on the copy handed to processSchema) and wire gated instantiation plus the instanceof guard via ObjectModifier. The nested-class type it stamps is reset to null afterwards and replaced by a `Nested | mixed` type hint, so the getter stays permissive -- the slot may hold the nested object or any other value the schema accepts. TypeCheckModifier(object) is deliberately not wired: it would hard-reject non-object values. - The universal 'any' modifiers run once, as before. - Concrete-type validator factories run for the remaining types. Each factory self-guards on keyword presence and each emitted validator self-gates on the runtime type, so only present keywords contribute a validator and none of them constrain the value's type. The non-factory type-shaping modifiers (TypeCheck, IntToFloat, DefaultArrayToEmptyArray, Null) are skipped because they are not keyword-gated and would reshape any-typed values. A bare {} activates neither the object nor the scalar layer and behaves exactly as before, so no empty nested classes are generated. Extract the validator source-key tagging into a shared runModifier helper and simplify createTypedProperty, which is no longer reachable with 'any'. Co-Authored-By: Claude Opus 4.8 --- docs/source/complexTypes/object.rst | 38 ++++ src/PropertyProcessor/PropertyFactory.php | 212 ++++++++++++++++-- .../UnevaluatedPropertiesValidatorTest.php | 32 ++- tests/Objects/AnyPropertyTest.php | 138 ++++++++++++ .../AnyPropertyArrayConstraint.json | 8 + .../AnyPropertyObjectAndStringConstraint.json | 14 ++ .../AnyPropertyStringConstraint.json | 8 + 7 files changed, 423 insertions(+), 27 deletions(-) create mode 100644 tests/Schema/AnyPropertyTest/AnyPropertyArrayConstraint.json create mode 100644 tests/Schema/AnyPropertyTest/AnyPropertyObjectAndStringConstraint.json create mode 100644 tests/Schema/AnyPropertyTest/AnyPropertyStringConstraint.json diff --git a/docs/source/complexTypes/object.rst b/docs/source/complexTypes/object.rst index 2bdec0d9..9ff0e515 100644 --- a/docs/source/complexTypes/object.rst +++ b/docs/source/complexTypes/object.rst @@ -85,6 +85,44 @@ If `error collection <../gettingStarted.html#collect-errors-vs-early-return>`__ If the class created for a nested object is instantiated manually you will either get a collection exception or a specific exception based on your error collection configuration if invalid data is provided. +Object applicators without an explicit type +------------------------------------------- + +A property subschema that declares object applicators — ``properties``, ``patternProperties``, ``additionalProperties``, ``unevaluatedProperties``, ``propertyNames``, ``minProperties`` or ``maxProperties`` — is treated as an object even when it omits ``type: object``. JSON Schema applicators are not gated on a type declaration, so the keywords apply whenever the provided value is an object. + +Because an untyped schema imposes no type constraint, a value of any other type is still accepted and passes through unchanged. The property is therefore *not* typed as the nested class alone: + +.. code-block:: json + + { + "type": "object", + "properties": { + "child": { + "properties": { + "known": { + "type": "string" + } + }, + "unevaluatedProperties": false + } + } + } + +Generated interface — an object value for ``child`` is wrapped in and validated by the generated nested class, while any other value is accepted unchanged: + +.. code-block:: php + + /** @return Child|mixed */ + public function getChild(): mixed; + +The native return type is ``mixed``, not ``Child | mixed``: ``mixed`` already subsumes every type, and PHP rejects it inside a union. The annotation carries the additional information that the value may be an instance of the generated nested class. + +A ``child`` object carrying an unevaluated key is rejected, while a scalar ``child`` value is accepted without modification. A bare untyped schema (``{}``) declares no applicators, so no nested class is generated and the property remains a plain ``mixed`` value. + +.. note:: + + The same principle applies to scalar and array applicators: a subschema declaring ``minLength`` without ``type: string``, or ``minItems`` without ``type: array``, enforces the constraint only when the value is of the matching type and accepts values of every other type. + Namespaces ---------- diff --git a/src/PropertyProcessor/PropertyFactory.php b/src/PropertyProcessor/PropertyFactory.php index ead0a325..45d38892 100644 --- a/src/PropertyProcessor/PropertyFactory.php +++ b/src/PropertyProcessor/PropertyFactory.php @@ -14,6 +14,7 @@ use Exception; use PHPModelGenerator\Draft\Draft; use PHPModelGenerator\Draft\DraftFactoryInterface; +use PHPModelGenerator\Draft\Modifier\ModifierInterface; use PHPModelGenerator\Draft\Modifier\ObjectType\ObjectModifier; use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory; use PHPModelGenerator\Draft\Modifier\TypeCheckModifier; @@ -99,6 +100,13 @@ public function create( $required, ), 'base' => $this->createBaseProperty($schemaProcessor, $schema, $propertyName, $propertySchema), + 'any' => $this->createUntypedProperty( + $schemaProcessor, + $schema, + $propertyName, + $propertySchema, + $required, + ), default => $this->createTypedProperty( $schemaProcessor, $schema, @@ -181,7 +189,9 @@ private function createBaseProperty( } /** - * Handle scalar, array, and untyped properties: construct directly and run all Draft modifiers. + * Handle a scalar or array property: construct directly and run all Draft modifiers. Untyped + * ('any') properties are routed to createUntypedProperty instead, so $type is always a concrete + * JSON Schema type here. * * @throws SchemaException */ @@ -193,11 +203,10 @@ private function createTypedProperty( string $type, bool $required, ): PropertyInterface { - $phpType = $type !== 'any' ? TypeConverter::jsonSchemaToPHP($type) : null; $property = $this->buildProperty( $schemaProcessor, $propertyName, - $phpType !== null ? new PropertyType($phpType) : null, + new PropertyType(TypeConverter::jsonSchemaToPHP($type)), $propertySchema, $required, ); @@ -207,6 +216,161 @@ private function createTypedProperty( return $property; } + /** + * Handle an untyped property (`type` absent → resolves to 'any'). JSON Schema applicators are + * not gated on a type declaration, so a subschema declaring object/scalar/array applicators + * must apply them whenever the instance is of the relevant type while still accepting values + * of every other type — an untyped schema imposes no type constraint. + * + * Three layers are wired onto a single, permissive (nullable/mixed) property: + * - object applicators (properties, patternProperties, unevaluatedProperties, …) generate a + * nested class and attach gated instantiation + instanceof, WITHOUT stamping the nested type; + * - the universal 'any' modifiers (enum, const, composition, if, not, filter, default) run once; + * - scalar/array applicators (minLength, minItems, …) attach their self-gating validators. + * + * A bare `{}` (or a schema carrying only universal keywords) activates neither the object nor + * the scalar layer and behaves exactly like the previous untyped handling. + * + * @throws SchemaException + */ + private function createUntypedProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + bool $required, + ): PropertyInterface { + $builtDraft = $this->resolveBuiltDraft($schemaProcessor, $propertySchema); + $property = $this->buildProperty($schemaProcessor, $propertyName, null, $propertySchema, $required); + + // Object applicators first so the instantiation decorator is registered before any filter + // decorators the universal modifiers add — mirrors the createObjectProperty ordering. + if ($this->hasObjectApplicator($builtDraft, $propertySchema->getJson())) { + $this->wireUntypedObjectClass($schemaProcessor, $schema, $property, $propertySchema, $propertyName); + } + + // Universal 'any' modifiers on the outer property. + $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema, anyOnly: true); + + // Scalar/array applicators (self-guarding on keyword presence, self-gating on runtime type). + $this->applyUntypedScalarModifiers($schemaProcessor, $schema, $property, $propertySchema, $builtDraft); + + return $property; + } + + /** + * Whether the given untyped schema carries at least one applicator keyword registered on the + * object type (properties, patternProperties, additionalProperties, unevaluatedProperties, + * minProperties, maxProperties, propertyNames, …). Only then is the nested-object class worth + * generating — a bare `{}` must not produce an empty class. + */ + private function hasObjectApplicator(Draft $builtDraft, array $json): bool + { + foreach (array_keys($json) as $keyword) { + if (in_array('object', $builtDraft->getTypesForKeyword($keyword), true)) { + return true; + } + } + + return false; + } + + /** + * Generate the nested object class for an untyped property that carries object applicators and + * wire gated instantiation onto the outer property, keeping the outer property permissive. + * + * @throws SchemaException + */ + private function wireUntypedObjectClass( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + string $propertyName, + ): void { + $className = $schemaProcessor->getGeneratorConfiguration()->getClassNameGenerator()->getClassName( + $propertyName, + $propertySchema, + false, + $schemaProcessor->getCurrentClassName(), + ); + + // Force `type: object` on the copy handed to processSchema so the nested class is generated; + // the outer property stays untyped. Property-level universal keywords target the outer + // property (handled by applyModifiers) and are stripped here, mirroring createObjectProperty. + $nestedJson = $propertySchema->getJson(); + unset($nestedJson['filter'], $nestedJson['enum'], $nestedJson['default']); + $nestedJson['type'] = 'object'; + + $nestedSchema = $schemaProcessor->processSchema( + $propertySchema->withJson($nestedJson), + $schemaProcessor->getCurrentClassPath(), + $className, + $schema->getSchemaDictionary(), + ); + + // Injecting `type: object` guarantees processSchema generates a class — its skip path only + // triggers for a non-object, non-composition, non-$ref root — so $nestedSchema is never null + // here. The guard mirrors createObjectProperty and degrades gracefully rather than fatally + // if a custom pipeline ever returns null. + if ($nestedSchema === null) { + return; + } + + $property->setNestedSchema($nestedSchema); + + // ObjectModifier attaches the array→Nested instantiation decorator and the instanceof guard; + // both self-gate at runtime (is_array / is_object) so a non-object value passes through + // untouched. It also stamps the nested-class type — required so InstanceOfValidator can name + // the class — which is immediately reset below to keep the getter permissive: the value may + // be the nested object OR any other type the untyped schema accepts, so the property is + // typed `Nested | mixed`, not `Nested`. TypeCheckModifier(object) is deliberately NOT wired: + // it would hard-reject non-object values and defeat "an untyped schema constrains nothing". + (new ObjectModifier())->modify($schemaProcessor, $schema, $property, $propertySchema); + + $property + ->setType(null) + ->addTypeHintDecorator(new TypeHintDecorator([$nestedSchema->getClassName(), 'mixed'])); + } + + /** + * Apply the concrete-type validator factories to an untyped property. Each factory self-guards + * on keyword presence and each emitted validator self-gates on the runtime type + * (e.g. `is_string($value) && …`), so running every concrete type's factories against an + * untyped value contributes a validator only for a keyword that is actually present and never + * constrains the value's type. + * + * The object type is skipped — its applicators are owned by the nested class wired in + * wireUntypedObjectClass — and so is 'any', whose modifiers already ran via applyModifiers. + * + * @throws SchemaException + */ + private function applyUntypedScalarModifiers( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + Draft $builtDraft, + ): void { + foreach ($builtDraft->getTypes() as $typeName => $type) { + if ($typeName === 'any' || $typeName === 'object') { + continue; + } + + foreach ($type->getModifiers() as $modifier) { + // Only the keyword-keyed validator factories are safe to run untyped. The remaining + // modifiers are type-shaping and NOT keyword-gated: TypeCheckModifier would impose a + // type constraint, IntToFloatModifier would cast every int, NullModifier would force + // null — none may run on a property that accepts any type. + if (!$modifier instanceof AbstractValidatorFactory) { + continue; + } + + $this->runModifier($modifier, $schemaProcessor, $schema, $property, $propertySchema); + } + } + } + /** * Construct a Property with the common required/readOnly/writeOnly setup. * @@ -641,21 +805,33 @@ private function applyModifiers( } foreach ($coveredType->getModifiers() as $modifier) { - $countBefore = count($property->getValidators()); - $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); - - // Tag every validator that was just added by this modifier with its schema - // keyword so FilterProcessor can later classify each validator as - // input-space or output-space relative to a transforming filter. - // This must cover all Draft-registered validators — not only those known - // to interact with filters today — because a custom Draft may register - // any validator factory under any type, and the classification must work - // without enumerating individual keywords. - if ($modifier instanceof AbstractValidatorFactory && ($modifierKey = $modifier->getKey()) !== null) { - foreach (array_slice($property->getValidators(), $countBefore) as $validatorWrapper) { - $validatorWrapper->setSourceKey($modifierKey); - } - } + $this->runModifier($modifier, $schemaProcessor, $schema, $property, $propertySchema); + } + } + } + + /** + * Run a single Draft modifier and tag every validator it just added with its schema keyword so + * FilterProcessor can later classify each validator as input-space or output-space relative to + * a transforming filter. This must cover all Draft-registered validators — not only those known + * to interact with filters today — because a custom Draft may register any validator factory + * under any type, and the classification must work without enumerating individual keywords. + * + * @throws SchemaException + */ + private function runModifier( + ModifierInterface $modifier, + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + $countBefore = count($property->getValidators()); + $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); + + if ($modifier instanceof AbstractValidatorFactory && ($modifierKey = $modifier->getKey()) !== null) { + foreach (array_slice($property->getValidators(), $countBefore) as $validatorWrapper) { + $validatorWrapper->setSourceKey($modifierKey); } } } diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index 42bd435e..3fcb36a4 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -476,20 +476,34 @@ public function testUnevaluatedPropertiesAppliesWithoutExplicitObjectTypeDeclara { $className = $this->generateClassFromFile('UntypedNestedSchemaWithUnevaluated.json'); - // Only the declared key is present — nothing is unevaluated inside `child`. + // Object value: only the declared key is present — nothing is unevaluated inside `child`. $accepted = new $className(['child' => ['known' => 'a']]); $this->assertSame(['child' => ['known' => 'a']], $accepted->meta()->rawInput()); - // `extra` is claimed by nothing inside `child` and must be rejected. - $this->expectException(NestedObjectException::class); - $this->expectExceptionMessageMatches( - <<<'REGEX' - /^Invalid nested object for property child: - - Provided JSON for .+ contains not allowed unevaluated properties \[extra\]$/ - REGEX, + // Non-object value: an untyped schema imposes no type constraint, so a scalar passes through + // untouched even though `child` declares object applicators. + $this->assertSame('a string', (new $className(['child' => 'a string']))->getChild()); + + // The getter stays permissive: the value may be the generated nested object OR any other + // type the untyped schema accepts, so the annotated return type is `|mixed`. + $this->assertMatchesRegularExpression( + '/^\w+\|mixed$/', + $this->getReturnTypeAnnotation($className, 'getChild'), ); - new $className(['child' => ['known' => 'a', 'extra' => 1]]); + // `extra` is claimed by nothing inside `child` and must be rejected by unevaluatedProperties. + try { + new $className(['child' => ['known' => 'a', 'extra' => 1]]); + $this->fail('Unevaluated key inside an untyped object subschema must be rejected'); + } catch (NestedObjectException $exception) { + $this->assertMatchesRegularExpression( + <<<'REGEX' + /^Invalid nested object for property child: + - Provided JSON for .+ contains not allowed unevaluated properties \[extra\]$/ + REGEX, + $exception->getMessage(), + ); + } } /** diff --git a/tests/Objects/AnyPropertyTest.php b/tests/Objects/AnyPropertyTest.php index fdf7e684..c05a6df6 100644 --- a/tests/Objects/AnyPropertyTest.php +++ b/tests/Objects/AnyPropertyTest.php @@ -4,9 +4,12 @@ namespace PHPModelGenerator\Tests\Objects; +use PHPModelGenerator\Exception\Arrays\MinItemsException; use PHPModelGenerator\Exception\FileSystemException; +use PHPModelGenerator\Exception\Object\NestedObjectException; use PHPModelGenerator\Exception\RenderException; use PHPModelGenerator\Exception\SchemaException; +use PHPModelGenerator\Exception\String\MinLengthException; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use stdClass; @@ -78,4 +81,139 @@ public static function validPropertyTypeDataProvider(): array ], ); } + + /** + * A string applicator (minLength) declared without an explicit `type: string` still applies — + * JSON Schema applicators are not gated on a type declaration. The emitted validator self-gates + * on `is_string`, so a value of any other type imposes no length constraint and passes through, + * while a string violating the constraint is rejected. + * + * @throws FileSystemException + * @throws RenderException + * @throws SchemaException + */ + public function testUntypedStringApplicatorSelfGates(): void + { + $className = $this->generateClassFromFile('AnyPropertyStringConstraint.json'); + + // Non-string values impose no length constraint and pass through unchanged. + $this->assertSame(42, (new $className(['property' => 42]))->getProperty()); + $this->assertSame([1], (new $className(['property' => [1]]))->getProperty()); + $this->assertNull((new $className([]))->getProperty()); + // A string satisfying minLength is accepted. + $this->assertSame('abcd', (new $className(['property' => 'abcd']))->getProperty()); + + // A string shorter than minLength is rejected — the applicator applies without `type: string`. + try { + new $className(['property' => 'ab']); + $this->fail('A string shorter than minLength must be rejected on an untyped property'); + } catch (MinLengthException $exception) { + $this->assertSame('Value for property must not be shorter than 3', $exception->getMessage()); + $this->assertSame('/properties/property/minLength', $exception->getJsonPointer()->pointer); + } + } + + /** + * An array applicator (minItems) declared without an explicit `type: array` still applies. The + * emitted validator self-gates on `is_array`, so a value of any other type passes through while + * an array violating the constraint is rejected. + * + * @throws FileSystemException + * @throws RenderException + * @throws SchemaException + */ + public function testUntypedArrayApplicatorSelfGates(): void + { + $className = $this->generateClassFromFile('AnyPropertyArrayConstraint.json'); + + // Non-array values impose no size constraint and pass through unchanged. + $this->assertSame('x', (new $className(['property' => 'x']))->getProperty()); + $this->assertSame(5, (new $className(['property' => 5]))->getProperty()); + $this->assertNull((new $className([]))->getProperty()); + // An array satisfying minItems is accepted. + $this->assertSame([1, 2], (new $className(['property' => [1, 2]]))->getProperty()); + + // An array shorter than minItems is rejected. + try { + new $className(['property' => [1]]); + $this->fail('An array shorter than minItems must be rejected on an untyped property'); + } catch (MinItemsException $exception) { + $this->assertSame( + 'Array property must not contain less than 2 items, 1 items provided', + $exception->getMessage(), + ); + $this->assertSame('/properties/property/minItems', $exception->getJsonPointer()->pointer); + } + } + + /** + * An untyped subschema may mix object and scalar applicators. Each self-gates independently: an + * object value is wrapped in the generated nested class and validated against the object + * applicators; a string value is validated against minLength; any other value is accepted. The + * getter stays permissive (`|mixed`) because the slot may hold the nested object OR + * any other type the untyped schema also accepts — it is not typed as the nested class alone. + * + * @throws FileSystemException + * @throws RenderException + * @throws SchemaException + */ + public function testUntypedMixedObjectAndScalarApplicatorsEachSelfGate(): void + { + $className = $this->generateClassFromFile('AnyPropertyObjectAndStringConstraint.json'); + + // Object value: the object applicators apply — the declared key is exposed through the + // generated nested class. + $object = (new $className(['property' => ['known' => 'a']]))->getProperty(); + $this->assertIsObject($object); + $this->assertSame('a', $object->getKnown()); + + // Non-object, non-string values impose no constraint and pass through. + $this->assertSame(42, (new $className(['property' => 42]))->getProperty()); + // A string satisfying minLength passes through unchanged (it is not wrapped as an object). + $this->assertSame('abcd', (new $className(['property' => 'abcd']))->getProperty()); + + // The getter is permissive: `|mixed` annotation over a `mixed` native type. + $this->assertMatchesRegularExpression( + '/^\w+\|mixed$/', + $this->getReturnTypeAnnotation($className, 'getProperty'), + ); + + // String applicator gates independently: a short string is rejected by minLength. + try { + new $className(['property' => 'ab']); + $this->fail('minLength must reject a short string even alongside object applicators'); + } catch (MinLengthException $exception) { + $this->assertSame('Value for property must not be shorter than 3', $exception->getMessage()); + $this->assertSame('/properties/property/minLength', $exception->getJsonPointer()->pointer); + } + + // Object applicator gates independently: an undeclared key is rejected by additionalProperties. + try { + new $className(['property' => ['known' => 'a', 'extra' => 1]]); + $this->fail('additionalProperties:false must reject an undeclared key on the nested object'); + } catch (NestedObjectException $exception) { + $this->assertMatchesRegularExpression( + <<<'REGEX' + /^Invalid nested object for property property: + - Provided JSON for .+ contains not allowed additional properties \[extra\]$/ + REGEX, + $exception->getMessage(), + ); + } + } + + /** + * A bare untyped schema (`{}`) carries no applicators, so no nested class is generated and the + * property stays fully permissive — the annotated return type is `mixed`, not a class union. + * + * @throws FileSystemException + * @throws RenderException + * @throws SchemaException + */ + public function testUntypedEmptySchemaRemainsMixedWithoutNestedClass(): void + { + $className = $this->generateClassFromFile('AnyProperty.json'); + + $this->assertSame('mixed', $this->getReturnTypeAnnotation($className, 'getProperty')); + } } diff --git a/tests/Schema/AnyPropertyTest/AnyPropertyArrayConstraint.json b/tests/Schema/AnyPropertyTest/AnyPropertyArrayConstraint.json new file mode 100644 index 00000000..8913d9f5 --- /dev/null +++ b/tests/Schema/AnyPropertyTest/AnyPropertyArrayConstraint.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "property": { + "minItems": 2 + } + } +} diff --git a/tests/Schema/AnyPropertyTest/AnyPropertyObjectAndStringConstraint.json b/tests/Schema/AnyPropertyTest/AnyPropertyObjectAndStringConstraint.json new file mode 100644 index 00000000..93090100 --- /dev/null +++ b/tests/Schema/AnyPropertyTest/AnyPropertyObjectAndStringConstraint.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "property": { + "properties": { + "known": { + "type": "string" + } + }, + "additionalProperties": false, + "minLength": 3 + } + } +} diff --git a/tests/Schema/AnyPropertyTest/AnyPropertyStringConstraint.json b/tests/Schema/AnyPropertyTest/AnyPropertyStringConstraint.json new file mode 100644 index 00000000..7ffceba1 --- /dev/null +++ b/tests/Schema/AnyPropertyTest/AnyPropertyStringConstraint.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "property": { + "minLength": 3 + } + } +} From c256512df303179014988e8f02e306c6a56b073b Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 3 Aug 2026 11:12:44 +0200 Subject: [PATCH 25/51] Reject dynamic-key writes a composition branch claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unevaluatedProperties()->set() and additionalProperties()->set() accessors could commit a value that a successful composition branch claims and rejects, leaving the model in a state its own constructor would refuse. Two independent defects caused this; either alone leaves the behaviour broken. _setUnevaluatedProperty never ran the composition validators at all — its comment wrongly asserted they cannot apply to a single dynamic key. A branch keyword that reaches keys the branch does not declare (additionalProperties, patternProperties, unevaluatedProperties, propertyNames, min/maxProperties) does decide whether the key is admissible. The setter now runs each _validateComposition_N against the candidate pair before the post-composition phase, with the same rollback discipline the additional-properties setter uses. The setter-side composition cache in ComposedItem.phptpl decided "nothing relevant changed" by intersecting the mutated keys against the branch's declared property names. A dynamic key never intersects that list, so the cache reported a hit and the branch was skipped — defeating even the additional-properties setter, which already invoked the validators. branchEvaluationDependsOnUndeclaredKeys() now gates the cache read: a branch whose outcome an undeclared key can flip is never served from the cache. Co-Authored-By: Claude Opus 4.8 --- .../Property/CompositionPropertyDecorator.php | 52 +++++++++++++++++-- .../SetUnevaluatedProperty.phptpl | 50 ++++++++++++++++-- src/Templates/Validator/ComposedItem.phptpl | 25 ++++++--- ...nalPropertiesAccessorPostProcessorTest.php | 49 +++++++++++++++++ ...tedPropertiesAccessorPostProcessorTest.php | 13 +++-- ...BranchAdditionalPropertiesTypesExtras.json | 20 +++++++ 6 files changed, 188 insertions(+), 21 deletions(-) create mode 100644 tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesTypesExtras.json diff --git a/src/Model/Property/CompositionPropertyDecorator.php b/src/Model/Property/CompositionPropertyDecorator.php index 665a05e2..09830356 100644 --- a/src/Model/Property/CompositionPropertyDecorator.php +++ b/src/Model/Property/CompositionPropertyDecorator.php @@ -9,15 +9,27 @@ use PHPModelGenerator\Model\SchemaDefinition\ResolvedDefinitionsCollection; use PHPModelGenerator\Utils\RenderHelper; -/** - * Class CompositionPropertyDecorator - * - * @package PHPModelGenerator\Model\Property - */ class CompositionPropertyDecorator extends PropertyProxy { private const string PROPERTY_KEY = 'composition'; + /** + * Object keywords whose outcome is decided by the shape or the count of the instance's keys + * rather than by the names listed in `properties`. A branch declaring any of them reacts to + * keys it never names, so no static name list can describe which mutations affect it. + */ + private const array UNDECLARED_KEY_SENSITIVE_KEYWORDS = [ + 'additionalProperties', + 'patternProperties', + 'unevaluatedProperties', + 'propertyNames', + 'minProperties', + 'maxProperties', + 'dependencies', + 'dependentRequired', + 'dependentSchemas', + ]; + /** * Store all properties from nested schemas of the composed property validator. If the composition validator fails * all affected properties must be set to null to adopt only valid values in the base model. @@ -163,6 +175,36 @@ public function branchClaimsAllKeys(): bool || ($this->jsonSchema->getJson()['unevaluatedProperties'] ?? null) === true; } + /** + * True when a key the branch does not declare in `properties` can still flip the branch's + * outcome. + * + * The setter-side validation cache decides "nothing relevant changed" by intersecting the + * mutated keys with this branch's declared property names. That test is only sound while + * every keyword in the branch reacts to names it declares. A branch carrying + * `additionalProperties`, `patternProperties`, `minProperties`, ... is decided by keys it + * never names, so the intersection reports "no change" for a mutation that does change the + * outcome and the branch is wrongly skipped. + * + * `required` is deliberately absent from the list even though a required name need not + * appear in `properties`: the schema processor materialises every required name as a + * property of the branch's nested schema, so such a name is already part of the declared + * set the cache tests against. Adding `required` here would disable the cache for a large + * share of real schemas without closing any gap. + */ + public function branchEvaluationDependsOnUndeclaredKeys(): bool + { + $branchJson = $this->jsonSchema->getJson(); + + foreach (self::UNDECLARED_KEY_SENSITIVE_KEYWORDS as $keyword) { + if (array_key_exists($keyword, $branchJson)) { + return true; + } + } + + return false; + } + /** * True when the branch declares `items` as a schema object (not a tuple list, not a * boolean). A schema-form `items` claims every index in the validated array. diff --git a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl index 94e5a120..a1605275 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl @@ -31,25 +31,67 @@ private function _setUnevaluatedProperty( {% if validationProperty %}{{ schemaHookResolver.resolveSetterBeforeValidationHook(validationProperty) }}{% endif %} - // _executePostCompositionValidators runs the unevaluatedProperties validator against the - // candidate pair, which writes the value into $this->_unevaluatedProperties on success. - // The schema's base + composition validators don't apply to a single dynamic key (the key - // isn't a declared property), so only the post-composition phase needs to run here. + // Snapshot the state a rejected call has to give back. In collect-errors mode a later phase + // can succeed and store its result while an earlier one has already registered an error, so + // the stores are unwound before the registry is thrown rather than left half-applied. // + // _propertyValidationState is deliberately not snapshotted: the composition cache is only + // read for branches whose outcome no undeclared key can change, and those branches are the + // ones a dynamic key leaves untouched. Every entry this call can rewrite belongs to a + // key-sensitive branch, which never reads the cache back. + $rollbackUnevaluatedProperties = $this->_unevaluatedProperties; + {% if schema.getCompositionValidatorKeys() %} + // A branch can flip from failing to succeeding once the new key is present (its + // minProperties is met, its required name arrives, ...). Leaving that slot behind after + // a rejection would credit the branch's claims to a state that never had them. + $rollbackCompositionEvaluations = $this->_compositionEvaluations; + {% endif %} + // In collect-errors mode each call needs an isolated error registry — without resetting it, // a previous setter that failed and was caught by the caller would leave stale errors that // make every subsequent setter throw spuriously when its own validation actually succeeded. {% if generatorConfiguration.collectErrors() %} $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% else %} + try { {% endif %} $addedProperty = [$key => $value]; + + {% if schema.getCompositionValidatorKeys() %} + // Cross-state composition revalidation: a branch keyword that reaches keys the branch + // does not declare — additionalProperties, patternProperties, unevaluatedProperties, + // propertyNames, min/maxProperties — decides whether the new key is admissible at all, + // and rejects it when its value violates the branch's claim. The compositions merge + // $addedProperty over _rawModelDataInput themselves, so the candidate state is visible + // to them without committing anything. + {% foreach schema.getCompositionValidatorKeys() as compositionValidatorKey %} + $this->_validateComposition_{{ compositionValidatorKey }}($addedProperty); + {% endforeach %} + {% endif %} + + // _executePostCompositionValidators runs the unevaluatedProperties validator against the + // candidate pair, which writes the value into $this->_unevaluatedProperties on success. $this->_executePostCompositionValidators($addedProperty); {% if generatorConfiguration.collectErrors() %} if (count($this->_errorRegistry->getErrors())) { + $this->_unevaluatedProperties = $rollbackUnevaluatedProperties; + {% if schema.getCompositionValidatorKeys() %} + $this->_compositionEvaluations = $rollbackCompositionEvaluations; + {% endif %} + throw $this->_errorRegistry; } + {% else %} + } catch (\Throwable $rejection) { + $this->_unevaluatedProperties = $rollbackUnevaluatedProperties; + {% if schema.getCompositionValidatorKeys() %} + $this->_compositionEvaluations = $rollbackCompositionEvaluations; + {% endif %} + + throw $rejection; + } {% endif %} $this->_rawModelDataInput[$key] = $value; diff --git a/src/Templates/Validator/ComposedItem.phptpl b/src/Templates/Validator/ComposedItem.phptpl index a91f1056..93939f96 100644 --- a/src/Templates/Validator/ComposedItem.phptpl +++ b/src/Templates/Validator/ComposedItem.phptpl @@ -46,14 +46,23 @@ // If none of the properties affected by the validator are changed the validator must not be re-evaluated if (isset($validatorIndex) && isset($this->_propertyValidationState[$validatorIndex][$validatorComponentIndex]) && - !array_intersect( - array_keys($modifiedModelData), - [ - {% foreach compositionProperty.getAffectedObjectProperties() as affectedObjectProperty %} - '{{ affectedObjectProperty.getName() }}', - {% endforeach %} - ] - ) + {% if compositionProperty.branchEvaluationDependsOnUndeclaredKeys() %} + // Never cached: this branch carries a keyword decided by the shape or + // count of the instance's keys (additionalProperties, patternProperties, + // minProperties, ...), so a key it does not declare can still flip its + // outcome. A declared-name test cannot see such a key and would wrongly + // report "nothing relevant changed", skipping a branch that now fails. + false + {% else %} + !array_intersect( + array_keys($modifiedModelData), + [ + {% foreach compositionProperty.getAffectedObjectProperties() as affectedObjectProperty %} + '{{ affectedObjectProperty.getName() }}', + {% endforeach %} + ] + ) + {% endif %} ) { {% if compositionValidator.hasEvaluationTrackingEnabled() %} // Cache hit: validators are not re-run, so $value still holds the diff --git a/tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php index 96ca41ec..b61dee82 100644 --- a/tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php @@ -6,6 +6,7 @@ use DateTime; use Exception; +use PHPModelGenerator\Exception\ComposedValue\AllOfException; use PHPModelGenerator\Exception\Object\InvalidAdditionalPropertiesException; use PHPModelGenerator\Exception\Object\InvalidPropertyNamesException; use PHPModelGenerator\Exception\Object\MaxPropertiesException; @@ -364,6 +365,54 @@ public function testMinPropertiesIsEnforcedWhenRemovingAPatternPropertyKey(): vo $accessor->remove('a1'); } + /** + * A composition branch declaring `additionalProperties` is decided by keys it never names, + * so the setter-side validation cache — which asks whether the mutated keys intersect the + * branch's *declared* property names — must not be consulted for it. A dynamic key never + * intersects that list, so a cached branch outcome would let `set()` store a value the + * branch rejects. + * + * Both entry points must agree: constructing with the same pair is rejected, so setting it + * has to be rejected too, and the rejected write must leave the raw model data untouched. + * + * The fixture declares `kind` twice on purpose. `additionalProperties` only exempts the + * `properties` of the schema object it sits in, so without the branch's own `kind` + * declaration the branch would treat the string `kind` as an additional property, fail its + * `type: integer` claim, and reject the constructor call this test needs to succeed. + */ + public function testSetIsRejectedByABranchAdditionalPropertiesClaimDespiteACachedBranchOutcome(): void + { + $this->addPostProcessor(true); + + $className = $this->generateClassFromFile( + 'BranchAdditionalPropertiesTypesExtras.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + // Construction populates the branch's cached outcome with "valid". + $object = new $className(['kind' => 'a']); + + // The same state the constructor refuses below, reached through the accessor instead. + try { + $object->additionalProperties()->set('extra', 'not-an-integer'); + $this->fail('Expected the branch additionalProperties claim to reject the value'); + } catch (AllOfException $exception) { + $this->assertSame( + <<getMessage(), + ); + } + + $this->assertSame([], $object->additionalProperties()->getAll()); + $this->assertSame(['kind' => 'a'], $object->meta()->rawInput()); + + $this->expectException(AllOfException::class); + new $className(['kind' => 'a', 'extra' => 'not-an-integer']); + } + public function testSetterSchemaHooksAreResolvedInSetAdditionalProperties(): void { $this->modifyModelGenerator = static function (ModelGenerator $modelGenerator): void { diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php index 2a419e28..fad24964 100644 --- a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -126,6 +126,11 @@ public function testSetRejectsValuesViolatingNonTypeConstraints(): void * same AllOfException the constructor raises for `{kind: "a", extra: "not-an-integer"}`. * In direct-exception mode the composition summary carries no per-element details, so the * message is fully constructible. + * + * The fixture declares `kind` twice on purpose. `additionalProperties` only exempts the + * `properties` of the schema object it sits in, so without the branch's own `kind` + * declaration the branch would treat the string `kind` as an additional property, fail its + * `type: integer` claim, and reject the constructor call this test needs to succeed. */ public function testSetRejectsValueViolatingASuccessfulBranchClaim(): void { @@ -399,7 +404,7 @@ public function testSetterInCollectErrorsModeIsolatesEachCall(): void try { $accessor->set('bad', 999); // exceeds maximum: 100 $this->fail('First set() was expected to throw'); - } catch (\PHPModelGenerator\Exception\ErrorRegistryException) { + } catch (ErrorRegistryException) { // expected — collect-errors mode wraps validation failures in this exception } @@ -633,7 +638,7 @@ public function testAdditionalAccessorSetIsRejectedWhenUnevaluatedFalseOrphansTh /** * `additionalProperties: true` + sibling `unevaluatedProperties: {type: integer}` is a - * dead-code cell in the §4.1 matrix: additionalProperties claims every extra at runtime, + * dead-code combination: additionalProperties claims every extra at runtime, * so the unevaluated validator and accessor are both suppressed at codegen. The user can * therefore call `$model->additionalProperties()->set(...)` with a value of any type — * the unevaluated schema's `type: integer` constraint never runs, and a string value @@ -885,7 +890,7 @@ public function testClaimedViaUnevaluatedKeyPersistsAfterSiblingLaterCovers(): v * in the registry. PatternProperties.phptpl rolls `_patternProperties` back so the * storage no longer records `x_foo` for this call's value. * - post-composition phase: collectUnevaluatedKeys correlates the pattern claim against - * `_patternProperties` to honour per-key validity (decision 0.5); the rolled-back + * `_patternProperties` to honour per-key validity; the rolled-back * storage means `x_foo` is *not* credited as evaluated, and unevaluatedProperties: false * rejects → UnevaluatedPropertiesException in the registry. * @@ -1015,7 +1020,7 @@ public function testAdditionalFalseRejectsExtrasAndSuppressesUnevaluatedAccessor $this->assertFalse(method_exists($object, 'unevaluatedProperties')); // Constructing with an extra throws on the additionalProperties: false validator. - $this->expectException(\PHPModelGenerator\Exception\ValidationException::class); + $this->expectException(ValidationException::class); new $className(['name' => 'Alice', 'extra' => 42]); } } diff --git a/tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesTypesExtras.json b/tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesTypesExtras.json new file mode 100644 index 00000000..8f8aee01 --- /dev/null +++ b/tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesTypesExtras.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "kind": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } + } + ] +} From 8d7a22af30fc9dc04f0774ad6e4cb821ef320819 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 3 Aug 2026 11:31:32 +0200 Subject: [PATCH 26/51] Revalidate compositions on removes and undeclared-key setters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in composition-aware setter/remove revalidation, all pre-existing and surfaced while fixing the dynamic-key setter. Each was reproduced red before the fix. A plain setter for a declared property never revalidated a composition branch whose outcome depends on keys the branch does not declare (a branch minProperties/maxProperties/additionalProperties/...). The composition post processor mapped a validator only to the properties its branches declared, so a sibling property declared on the outer schema but on no branch got no revalidation call and could commit a value the branch rejects. A key-sensitive branch now maps its validator to every declared property. _removeUnevaluatedProperty ran only its local minProperties check and performed no composition or post-composition revalidation, unlike the additionalProperties remove path it should mirror. Removing a key could flip a branch (dropping below a branch minProperties, orphaning a key a branch used to claim) and leave a model its own constructor would reject. The remove template now snapshots the mutated fields, revalidates the post-removal state, and rolls back on rejection. The composition template caches each branch's outcome in _propertyValidationState for every mutable composition, but the field was declared only when some branch declared a property. A composition whose branches declare none (e.g. allOf: [{minProperties: 3}]) left the field undeclared, so the template's write created it dynamically — a deprecation as of PHP 8.4. The field is now declared for every mutable composition regardless of branch shape. Co-Authored-By: Claude Opus 4.8 --- ...aluatedPropertiesAccessorPostProcessor.rst | 7 +- .../CompositionValidationPostProcessor.php | 101 +++++++++++++----- .../RemoveUnevaluatedProperty.phptpl | 67 ++++++++++-- tests/ComposedValue/ComposedAllOfTest.php | 93 ++++++++++++++++ ...tedPropertiesAccessorPostProcessorTest.php | 49 +++++++++ ...chMaxPropertiesReactsToUndeclaredKeys.json | 16 +++ ...sitionBranchWithoutDeclaredProperties.json | 8 ++ .../BranchMinPropertiesClaimsExtras.json | 16 +++ 8 files changed, 325 insertions(+), 32 deletions(-) create mode 100644 tests/Schema/ComposedAllOfTest/BranchMaxPropertiesReactsToUndeclaredKeys.json create mode 100644 tests/Schema/ComposedAllOfTest/MutableCompositionBranchWithoutDeclaredProperties.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchMinPropertiesClaimsExtras.json diff --git a/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst b/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst index 94ae15d1..741c151a 100644 --- a/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst +++ b/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst @@ -94,8 +94,11 @@ into the coverage of another sibling applicator (or vice versa) is rejected up f schema property. **remove**: Removes an existing unevaluated property from the model. Returns true if the property -was removed, false if it did not exist. May throw a *MinPropertiesException* if removal would -produce an invalid model state. +was removed, false if it did not exist. Re-runs the enclosing schema's validation against the +post-removal state — including composition re-evaluation — and throws a *ValidationException* if +the removal would produce an invalid model (for example dropping below a ``minProperties`` +constraint or flipping a composition branch that a remaining key depended on), leaving the model +unchanged. Serialization ~~~~~~~~~~~~~ diff --git a/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php index 6bd5974b..c34847f5 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php @@ -35,7 +35,18 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu $this->addValidationMethods($schema, $generatorConfiguration, $compositionValidatorKeys); // if the generator is immutable no validation on value updates are required - if ($generatorConfiguration->isImmutable() || empty($validatorPropertyMap)) { + if ($generatorConfiguration->isImmutable()) { + return; + } + + // The composition template caches each branch's outcome in _propertyValidationState for + // every mutable base composition — even one whose branches declare no properties (e.g. + // allOf: [{minProperties: 3}]) and thus produce an empty map. Declare the field whenever + // the model is mutable and carries a composition so those writes never create a dynamic + // property, which PHP 8.4 deprecates. + $this->addPropertyValidationStateField($schema, $validatorPropertyMap); + + if (empty($validatorPropertyMap)) { return; } @@ -56,7 +67,18 @@ private function generateValidatorPropertyMap(Schema $schema): array continue; } + $dependsOnUndeclaredKeys = false; + foreach ($validator->getComposedProperties() as $composedProperty) { + // A branch keyword decided by the shape or count of the instance's keys + // (additionalProperties, patternProperties, min/maxProperties, ...) can flip its + // outcome when a key the branch does not declare changes. No declared-name list + // describes which mutations affect it, so every setter must re-run the whole + // composition — see the mapping to all schema properties below. + if ($composedProperty->branchEvaluationDependsOnUndeclaredKeys()) { + $dependsOnUndeclaredKeys = true; + } + // Every schema-level composition branch has a nested schema at this point: // SchemaProcessor::transferComposedPropertiesToSchema() throws SchemaException // for any branch that lacks one, and inheritPropertyType() forces branches to @@ -66,36 +88,67 @@ private function generateValidatorPropertyMap(Schema $schema): array // for inline oneOf/anyOf/if-then-else discriminators without a separate // harvest path. foreach ($composedProperty->getNestedSchema()->getProperties() as $property) { - if (!isset($validatorPropertyMap[$property->getName()])) { - $validatorPropertyMap[$property->getName()] = []; - } + $this->mapPropertyToValidator($validatorPropertyMap, $property->getName(), $validatorIndex); + } + } + + if (!$dependsOnUndeclaredKeys) { + continue; + } - $validatorPropertyMap[$property->getName()][] = $validatorIndex; + // A key-sensitive branch reacts to keys no branch declares, so mapping the validator + // only to branch-declared names would leave a plain setter for a sibling property + // (declared on the outer schema but on no branch) without a revalidation call — that + // setter could then commit a value the branch rejects. Map the validator to every + // declared property so all of them revalidate the composition. + foreach ($schema->getProperties() as $property) { + if (!$property->isInternal()) { + $this->mapPropertyToValidator($validatorPropertyMap, $property->getName(), $validatorIndex); } } } - if (!empty($validatorPropertyMap)) { - $schema->addProperty( - (new Property( - 'propertyValidationState', - new PropertyType('array'), - new JsonSchema(__FILE__, []), - 'Track the internal validation state of composed validations', - )) - ->setInternal(true) - ->setDefaultValue( - array_fill_keys( - array_unique( - array_merge(...array_values($validatorPropertyMap)), - ), - [], - ) - ), - ); + return $validatorPropertyMap; + } + + /** + * Append $validatorIndex to the property's entry in the map, creating the entry when absent. + * Duplicate indexes are tolerated: every consumer dedups (the setter hook via array_unique, + * the field default via array_unique). + */ + private function mapPropertyToValidator( + array &$validatorPropertyMap, + string $propertyName, + int $validatorIndex, + ): void { + if (!isset($validatorPropertyMap[$propertyName])) { + $validatorPropertyMap[$propertyName] = []; } - return $validatorPropertyMap; + $validatorPropertyMap[$propertyName][] = $validatorIndex; + } + + /** + * Declare the _propertyValidationState cache field. The default seeds one empty slot per + * composition validator index that appears in the map; the composition template auto-vivifies + * any further index it writes, so an empty default (empty map) is valid too. + */ + private function addPropertyValidationStateField(Schema $schema, array $validatorPropertyMap): void + { + $seededValidatorIndexes = $validatorPropertyMap === [] + ? [] + : array_unique(array_merge(...array_values($validatorPropertyMap))); + + $schema->addProperty( + (new Property( + 'propertyValidationState', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + 'Track the internal validation state of composed validations', + )) + ->setInternal(true) + ->setDefaultValue(array_fill_keys($seededValidatorIndexes, [])), + ); } /** diff --git a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl index 18c395eb..dff53fbf 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl @@ -1,5 +1,5 @@ /** - * {% if minPropertyValidator %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} + * {% if minPropertyValidator or schema.getCompositionValidatorKeys() or schema.getPostCompositionValidators() %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} */ private function _removeUnevaluatedProperty(string $key): bool { @@ -7,24 +7,79 @@ private function _removeUnevaluatedProperty(string $key): bool return false; } - {% if minPropertyValidator %} + {% if minPropertyValidator or schema.getCompositionValidatorKeys() or schema.getPostCompositionValidators() %} + // Snapshot every field a rejected removal must restore. The composition validators merge + // against _rawModelDataInput when they run, so the post-removal view has to live there — + // a candidate parameter alone would be re-merged with the still-present key. + $originalRawModelDataInput = $this->_rawModelDataInput; + $originalUnevaluatedProperties = $this->_unevaluatedProperties; + + {% if schema.getCompositionValidatorKeys() %} + $originalCompositionEvaluations = $this->_compositionEvaluations ?? []; + $originalPropertyValidationState = $this->_propertyValidationState ?? []; + {% endif %} + {% if generatorConfiguration.collectErrors() %} $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% else %} + try { {% endif %} - if ({{ minPropertyValidator.getCheck() }}) { - {{ viewHelper.validationError(minPropertyValidator) }} - } + {% if minPropertyValidator %} + // minPropertyValidator's check expression compensates for the imminent removal + // (`count($this->_rawModelDataInput) - 1`), so it must run BEFORE the unset. + if ({{ minPropertyValidator.getCheck() }}) { + {{ viewHelper.validationError(minPropertyValidator) }} + } + {% endif %} + + unset($this->_rawModelDataInput[$key]); + + {% if schema.getCompositionValidatorKeys() %} + // Cross-state composition revalidation: removing a key may flip a branch whose + // outcome depends on which keys are present (a branch minProperties, a required name, + // an additionalProperties claim). Without this the cached _compositionEvaluations + // stay tied to the pre-removal state and a now-invalid model would be accepted. + {% foreach schema.getCompositionValidatorKeys() as compositionValidatorKey %} + $this->_validateComposition_{{ compositionValidatorKey }}($this->_rawModelDataInput); + {% endforeach %} + {% endif %} + + {% if schema.getPostCompositionValidators() %} + // The unevaluatedProperties validator rebuilds _unevaluatedProperties from the + // post-removal state; removing a key can orphan another that a now-failed branch + // used to claim, which this phase rejects. + $this->_executePostCompositionValidators($this->_rawModelDataInput); + {% endif %} {% if generatorConfiguration.collectErrors() %} if (count($this->_errorRegistry->getErrors())) { + $this->_rawModelDataInput = $originalRawModelDataInput; + $this->_unevaluatedProperties = $originalUnevaluatedProperties; + {% if schema.getCompositionValidatorKeys() %} + $this->_compositionEvaluations = $originalCompositionEvaluations; + $this->_propertyValidationState = $originalPropertyValidationState; + {% endif %} + throw $this->_errorRegistry; } + {% else %} + } catch (\Throwable $rejection) { + $this->_rawModelDataInput = $originalRawModelDataInput; + $this->_unevaluatedProperties = $originalUnevaluatedProperties; + {% if schema.getCompositionValidatorKeys() %} + $this->_compositionEvaluations = $originalCompositionEvaluations; + $this->_propertyValidationState = $originalPropertyValidationState; + {% endif %} + + throw $rejection; + } {% endif %} + {% else %} + unset($this->_rawModelDataInput[$key]); {% endif %} unset($this->_unevaluatedProperties[$key]); - unset($this->_rawModelDataInput[$key]); return true; } diff --git a/tests/ComposedValue/ComposedAllOfTest.php b/tests/ComposedValue/ComposedAllOfTest.php index 7e288da8..15a8a933 100644 --- a/tests/ComposedValue/ComposedAllOfTest.php +++ b/tests/ComposedValue/ComposedAllOfTest.php @@ -10,9 +10,11 @@ use PHPModelGenerator\Exception\ValidationException; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; +use ReflectionClass; use ReflectionMethod; use stdClass; use PHPModelGenerator\Tests\Support\ApplicableDrafts; +use PHPModelGenerator\Tests\Support\JsonSchemaDraft; use PHPUnit\Framework\Attributes\DataProvider; /** @@ -707,4 +709,95 @@ public function testAllOfNullableTypesWithEmptyNonNullIntersectionReturnsEarlyWi $object->setProperty(null); $this->assertNull($object->getProperty()); } + + /** + * A mutable object-level allOf whose branch declares no `properties` (here it carries only + * `minProperties`) still runs through the composition template, which caches each branch's + * outcome in `_propertyValidationState`. The composition post processor used to declare that + * field only when at least one branch declared a property, so a branch like this one left the + * field undeclared and the template's write created it dynamically — a deprecation as of PHP + * 8.4. The field must be declared for every mutable composition regardless of branch shape. + * + * Draft-pinned: the behaviour is a property of the composition post processor and does not + * vary by draft, so one run is sufficient. + */ + #[ApplicableDrafts(from: JsonSchemaDraft::DRAFT_2019_09, until: JsonSchemaDraft::DRAFT_2019_09)] + public function testMutableCompositionBranchWithoutDeclaredPropertiesDeclaresValidationStateField(): void + { + $className = $this->generateClassFromFile( + 'MutableCompositionBranchWithoutDeclaredProperties.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + // The cache field is declared, so the template's write targets a real property. + $this->assertTrue((new ReflectionClass($className))->hasProperty('_propertyValidationState')); + + // Constructing a valid instance exercises the write path; capture any deprecation the + // write would raise if the field were only created dynamically. + $deprecations = []; + set_error_handler( + static function (int $severity, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, + E_DEPRECATED, + ); + + try { + $object = new $className(['name' => 'Alice']); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $deprecations); + $this->assertSame(['name' => 'Alice'], $object->meta()->rawInput()); + } + + /** + * A declared property that no composition branch declares must still revalidate a branch + * whose outcome depends on keys the branch does not name — here `maxProperties: 1`. Setting + * `other` raises the key count to 2, which the branch rejects, so the setter must throw and + * roll the model back rather than commit a state its own constructor would refuse. + * + * The composition post processor previously mapped a validator only to the properties its + * branches declared; this branch declares none, so `setOther` received no revalidation call + * and silently accepted the second key. A key-sensitive branch now maps to every declared + * property. + * + * Draft-pinned: the setter-side revalidation mapping does not vary by draft. + */ + #[ApplicableDrafts(from: JsonSchemaDraft::DRAFT_2019_09, until: JsonSchemaDraft::DRAFT_2019_09)] + public function testSetterRevalidatesBranchThatReactsToUndeclaredKeys(): void + { + $className = $this->generateClassFromFile( + 'BranchMaxPropertiesReactsToUndeclaredKeys.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + // One key: the branch's maxProperties: 1 is satisfied. + $object = new $className(['kind' => 'a']); + + try { + // `other` is a valid string, but adding it makes two keys — the branch rejects it. + $object->setOther('second'); + $this->fail('Expected the branch maxProperties claim to reject the second key'); + } catch (AllOfException $exception) { + $this->assertSame( + <<getMessage(), + ); + } + + // Rollback discipline: the rejected setter left the model at its pre-call state. + $this->assertNull($object->getOther()); + $this->assertSame(['kind' => 'a'], $object->meta()->rawInput()); + + // The constructor refuses the same two-key state, proving the setter now agrees with it. + $this->expectException(AllOfException::class); + new $className(['kind' => 'a', 'other' => 'second']); + } } diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php index fad24964..b975b466 100644 --- a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -824,6 +824,55 @@ public function testRemoveRejectsCountDropBelowMinPropertiesAndRollsBack(): void $this->assertSame(['name' => 'Alice', 'a' => 1, 'b' => 2], $object->meta()->rawInput()); } + /** + * remove() must revalidate the post-removal state, not just delete the key. A composition + * branch here requires `minProperties: 3`; removing one of three keys drops the count to 2 + * and the branch fails, so the removal must throw and roll the model back. Without the + * revalidation the shim deleted the key and left a model its own constructor would reject. + * + * The remove shim previously ran only its local `minProperties` check (absent here, since + * the constraint lives inside the branch) and performed no composition revalidation at all, + * unlike the additionalProperties remove path it should mirror. + */ + public function testRemoveRevalidatesCompositionAndRollsBackOnRejection(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'BranchMinPropertiesClaimsExtras.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + // Three keys: the branch's minProperties: 3 is satisfied; the two extras are unevaluated. + $object = new $className(['kind' => 'a', 'first' => 'x', 'second' => 'y']); + $accessor = $object->unevaluatedProperties(); + $this->assertSame(['first' => 'x', 'second' => 'y'], $accessor->getAll()); + + try { + // Removing one extra drops the count to 2 — the branch's minProperties: 3 fails. + $accessor->remove('first'); + $this->fail('Expected the branch minProperties claim to reject the removal'); + } catch (AllOfException $exception) { + $this->assertSame( + <<getMessage(), + ); + } + + // Rollback discipline: both the backing field and the raw input are unchanged. + $this->assertSame(['first' => 'x', 'second' => 'y'], $accessor->getAll()); + $this->assertSame( + ['kind' => 'a', 'first' => 'x', 'second' => 'y'], + $object->meta()->rawInput(), + ); + + // The constructor refuses the same post-removal state, proving remove() agrees with it. + $this->expectException(AllOfException::class); + new $className(['kind' => 'a', 'second' => 'y']); + } + /** * A key claimed via the unevaluated accessor stays in `_unevaluatedProperties` even after * a later mutation makes a composition branch claim the same key. The accumulator-rebuild diff --git a/tests/Schema/ComposedAllOfTest/BranchMaxPropertiesReactsToUndeclaredKeys.json b/tests/Schema/ComposedAllOfTest/BranchMaxPropertiesReactsToUndeclaredKeys.json new file mode 100644 index 00000000..92b9bfdb --- /dev/null +++ b/tests/Schema/ComposedAllOfTest/BranchMaxPropertiesReactsToUndeclaredKeys.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "other": { + "type": "string" + } + }, + "allOf": [ + { + "maxProperties": 1 + } + ] +} diff --git a/tests/Schema/ComposedAllOfTest/MutableCompositionBranchWithoutDeclaredProperties.json b/tests/Schema/ComposedAllOfTest/MutableCompositionBranchWithoutDeclaredProperties.json new file mode 100644 index 00000000..97c8795c --- /dev/null +++ b/tests/Schema/ComposedAllOfTest/MutableCompositionBranchWithoutDeclaredProperties.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "allOf": [ + { + "minProperties": 1 + } + ] +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchMinPropertiesClaimsExtras.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchMinPropertiesClaimsExtras.json new file mode 100644 index 00000000..28cdb640 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchMinPropertiesClaimsExtras.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "allOf": [ + { + "minProperties": 3 + } + ], + "unevaluatedProperties": { + "type": "string" + } +} From 716f28c1b9272e012fc2ebe68bd52154da1d6ea4 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 13 Aug 2026 16:57:19 +0200 Subject: [PATCH 27/51] Fix stray trailing whitespace in conditional @throws docblocks RemoveAdditionalProperty.phptpl, SetAdditionalProperty.phptpl, and RemoveUnevaluatedProperty.phptpl each had a literal space before a {% if %}...{% endif %} block emitting @throws. When the condition is false the block renders nothing, leaving the docblock line as a bare " *" with a trailing space. Moved the space inside the conditional so it only appears alongside actual content. Co-Authored-By: Claude Sonnet 5 --- .../AdditionalProperties/RemoveAdditionalProperty.phptpl | 2 +- .../AdditionalProperties/SetAdditionalProperty.phptpl | 4 ++-- .../UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl index 678b10c1..28cff116 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl @@ -1,5 +1,5 @@ /** - * {% if minPropertyValidator or schema.getCompositionValidatorKeys() or schema.getPostCompositionValidators() %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} + *{% if minPropertyValidator or schema.getCompositionValidatorKeys() or schema.getPostCompositionValidators() %} @throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} */ private function _removeAdditionalProperty(string $key): bool { diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl index c7bce47a..8931f4c4 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl @@ -1,6 +1,6 @@ /** - * {% if hasObjectProperties %}@throws RegularPropertyAsAdditionalPropertyException - * {% endif %}{% if schema.getBaseValidators() or schema.getPostCompositionValidators() %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} + *{% if hasObjectProperties %} @throws RegularPropertyAsAdditionalPropertyException + *{% endif %}{% if schema.getBaseValidators() or schema.getPostCompositionValidators() %} @throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} */ private function _setAdditionalProperty( string $key, diff --git a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl index dff53fbf..1ad6651d 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl @@ -1,5 +1,5 @@ /** - * {% if minPropertyValidator or schema.getCompositionValidatorKeys() or schema.getPostCompositionValidators() %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} + *{% if minPropertyValidator or schema.getCompositionValidatorKeys() or schema.getPostCompositionValidators() %} @throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} */ private function _removeUnevaluatedProperty(string $key): bool { From 75c90684eee088842a234bf31d2b2593dc50a265 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 13 Aug 2026 16:57:40 +0200 Subject: [PATCH 28/51] Add regression test for composition dropped inside array branches Recovers testCompositionNestedInsideArrayBranchIsNotSilentlyDropped and its fixture, which had only ever been git-stashed before the master merge and never actually landed on the branch. Marked incomplete: a composition nested inside a composition branch on an array-typed property is silently dropped during generation, a pre-existing gap in the composition rendering path unrelated to unevaluatedItems itself. Co-Authored-By: Claude Sonnet 5 --- tests/Basic/UnevaluatedItemsValidatorTest.php | 26 +++++++++++++++++++ ...itionInsideArrayBranchSilentlyDropped.json | 19 ++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/NestedCompositionInsideArrayBranchSilentlyDropped.json diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index d36c5697..46595be5 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -633,6 +633,32 @@ public function testSelfReferencingArrayCompositionDoesNotRecurseIndefinitely(): ); } + /** + * A composition nested inside a composition branch on an array-typed property is + * silently dropped during generation — not just its unevaluatedItems keyword, the + * entire nested composition and everything inside it. Confirmed independent of + * unevaluatedItems: the same fixture shape with a plain `minItems` in place of + * `unevaluatedItems` inside the inner branch produces identical output (the inner + * `oneOf` never renders at all, no error, no warning). The object-typed equivalent + * (`{type: object, allOf: [{oneOf: [{minProperties: ...}]}]}`) generates correctly — + * each composition level gets its own nested class — so the gap is specific to array + * properties, which never carry a nested schema for a branch's own composition to + * attach to. This is a pre-existing generation-pipeline gap, not introduced by the + * unevaluatedItems work; fixing it belongs to the composition rendering path, not to + * this feature's post processor or validators (deferred bug; tracked in the + * implementation plan). + */ + public function testCompositionNestedInsideArrayBranchIsNotSilentlyDropped(): void + { + $this->markTestIncomplete( + 'A composition nested inside a composition branch on an array-typed property ' + . 'is silently dropped during generation (confirmed independent of ' + . 'unevaluatedItems — a plain minItems in the same position is dropped too). ' + . 'The object-typed equivalent generates correctly, so the gap is array-specific ' + . '(deferred bug; tracked in the implementation plan).', + ); + } + /** * Spec propagation: a nested `unevaluatedItems` schema-form validator inside a * successful `allOf` branch must contribute its claimed indices to the enclosing diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/NestedCompositionInsideArrayBranchSilentlyDropped.json b/tests/Schema/UnevaluatedItemsValidatorTest/NestedCompositionInsideArrayBranchSilentlyDropped.json new file mode 100644 index 00000000..eed84bc3 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/NestedCompositionInsideArrayBranchSilentlyDropped.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "oneOf": [ + { + "unevaluatedItems": { + "type": "string" + } + } + ] + } + ] + } + } +} From 425f6e15f99fa2f7990b867550bc9e01103958d0 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 13 Aug 2026 17:13:31 +0200 Subject: [PATCH 29/51] Close remaining Phase 2 coverage gaps for unevaluatedItems/Properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three remaining Phase 2 coverage-gap-test-plan items: - A bare boolean `true` composition branch under `unevaluatedProperties` contributes nothing to the evaluated set and does not crash on the branch's absent nested schema. - `unevaluatedItems` declared on a non-array-typed property is silently ignored (no array-index validator is emitted), while a sibling `unevaluatedProperties: false` still activates normally. - `if`/`then`/`else` on an array property under `unevaluatedItems` credits whichever of `then`/`else` actually ran, exercising ConditionalComposedItem.phptpl's array-side branch for the first time. No defect found — this closes a coverage gap in already- correct code, not a bug fix. Co-Authored-By: Claude Sonnet 5 --- tests/Basic/UnevaluatedItemsValidatorTest.php | 82 +++++++++++++++++++ .../UnevaluatedPropertiesValidatorTest.php | 34 ++++++++ .../IfThenElseArrayUnevaluatedItems.json | 33 ++++++++ .../UnevaluatedItemsOnNonArrayProperty.json | 10 +++ ...sTrueBranchContributesNoEvaluatedKeys.json | 19 +++++ 5 files changed, 178 insertions(+) create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/IfThenElseArrayUnevaluatedItems.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedItemsOnNonArrayProperty.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/AlwaysTrueBranchContributesNoEvaluatedKeys.json diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 46595be5..ef0808c3 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -11,6 +11,7 @@ use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\ComposedValue\AllOfException; use PHPModelGenerator\Exception\Generic\InvalidTypeException; +use PHPModelGenerator\Exception\Object\UnevaluatedPropertiesException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; @@ -405,6 +406,36 @@ public function testInvalidTypeForKeywordThrowsSchemaException( $this->generateClassFromFile($schemaFile); } + /** + * `unevaluatedItems` declared on a non-array-typed property is inapplicable per spec — array + * applicators impose no constraint on a value that is not an array — and must be silently + * ignored: no array-index validator is emitted for the property, and the property behaves + * exactly as if `unevaluatedItems` were absent. Regression guard for + * `activateArrayPropertyTracking`'s type-mismatch skip actually being a no-op rather than a + * state-corruption path (e.g. wiring up index-tracking machinery for a scalar property, or + * suppressing an unrelated sibling keyword). A sibling `unevaluatedProperties: false` on the + * parent still activates and works normally, proving this property's malformed-for-its-type + * keyword does not interfere with unrelated validators on the class. + */ + public function testUnevaluatedItemsOnNonArrayPropertyIsSilentlyIgnored(): void + { + $className = $this->generateClassFromFile('UnevaluatedItemsOnNonArrayProperty.json'); + + $accepted = new $className(['tags' => 'hello']); + $this->assertSame('hello', $accepted->getTags()); + $this->assertSame(['tags' => 'hello'], $accepted->meta()->rawInput()); + + try { + new $className(['tags' => 'hello', 'extra' => 1]); + $this->fail('unevaluatedProperties: false on the parent must still reject unclaimed keys'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['extra']", + $exception->getMessage(), + ); + } + } + /** * Composition branches contribute their evaluated indices to a sibling unevaluatedItems * accumulator. The fixture has two tuple-form items branches under allOf: branch 1 covers @@ -792,6 +823,57 @@ public function testOneOfBranchesOfDifferentTupleLengthsControlEvaluatedSet(): v } } + /** + * `if`/`then`/`else` on an array property: whichever of `then`/`else` actually runs + * determines how many indices are evaluated, exercising `ConditionalComposedItem.phptpl`'s + * array-side branch. The fixture's `if` checks whether index 0 is the literal `"typed"`: + * when it is, `then` requires a 2-tuple (`"typed"` then any string) and covers indices 0-1; + * otherwise `else` requires a 1-tuple (an integer) and covers only index 0. `unevaluatedItems: + * false` rejects whatever index neither branch of the winning path covers. + * + * Four assertions on the same generated class: + * - `['typed', 'x']` — `if` passes, `then` covers both indices, accepted; + * - `['typed', 'x', 'extra']` — `if` passes, `then` covers 0-1, index 2 unevaluated; + * - `[5]` — `if` fails (index 0 is not `"typed"`), `else` covers index 0, accepted; + * - `[5, 6]` — `if` fails, `else` covers only index 0, index 1 unevaluated. + */ + public function testIfThenElseArrayCompositionCreditsTheWinningBranchesIndices(): void + { + $className = $this->generateClassFromFile('IfThenElseArrayUnevaluatedItems.json'); + + $acceptedThen = new $className(['tags' => ['typed', 'x']]); + $this->assertSame(['typed', 'x'], $acceptedThen->getTags()); + + try { + new $className(['tags' => ['typed', 'x', 'extra']]); + $this->fail('Expected UnevaluatedItemsException for the index past the then-branch tuple'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#2]", + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } + + $acceptedElse = new $className(['tags' => [5]]); + $this->assertSame([5], $acceptedElse->getTags()); + + try { + new $className(['tags' => [5, 6]]); + $this->fail('Expected UnevaluatedItemsException for the index past the else-branch tuple'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#1]", + $exception->getMessage(), + ); + $this->assertSame([1], $exception->getUnevaluatedItems()); + $this->assertSame( + '/properties/tags/unevaluatedItems', + $exception->getJsonPointer()->pointer, + ); + } + } + /** * A transforming filter (here `dateTime`) declared inside `unevaluatedItems`'s subschema * behaves like a filter on any other property: the transformed value is persisted (the diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index 5fbf80dc..b450fee4 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -320,6 +320,40 @@ public function testEmptyAllOfStillEnforcesUnevaluatedAtOuterLevel(): void } } + /** + * A bare boolean `true` composition element is spec-legal inside `allOf` — it always + * succeeds and contributes no declared properties of its own. Regression guard for a + * null-nested-schema dereference: the always-true branch is represented by + * `createAlwaysTrueBranchProperty()` and has no nested `Schema`, so the accumulator must + * skip it rather than crash trying to read declared property names off it. The sibling + * real branch (`{properties: {kind}}`) still contributes `kind` normally. + * + * Two scenarios on the same generated class: + * - `{name, kind}` accepted — the real branch's `kind` is evaluated, `name` is a + * declared outer property; + * - `{name, kind, extra}` rejected — the always-true branch contributes nothing to the + * evaluated set, so `extra` remains unevaluated. + */ + public function testAlwaysTrueCompositionBranchContributesNoEvaluatedKeys(): void + { + $className = $this->generateClassFromFile('AlwaysTrueBranchContributesNoEvaluatedKeys.json'); + + $accepted = new $className(['name' => 'Alice', 'kind' => 'X']); + $this->assertSame(['name' => 'Alice', 'kind' => 'X'], $accepted->meta()->rawInput()); + + try { + new $className(['name' => 'Alice', 'kind' => 'X', 'extra' => 1]); + $this->fail('An always-true branch must not rescue extras from unevaluatedProperties: false'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['extra']", + $exception->getMessage(), + ); + $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); + $this->assertSame('/unevaluatedProperties', $exception->getJsonPointer()->pointer); + } + } + /** * A composition branch that declares its own `unevaluatedProperties: {schema}` records * the keys it evaluates against that schema. Those records must propagate to an diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/IfThenElseArrayUnevaluatedItems.json b/tests/Schema/UnevaluatedItemsValidatorTest/IfThenElseArrayUnevaluatedItems.json new file mode 100644 index 00000000..87b374e8 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/IfThenElseArrayUnevaluatedItems.json @@ -0,0 +1,33 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "if": { + "items": [ + { + "const": "typed" + } + ] + }, + "then": { + "items": [ + { + "const": "typed" + }, + { + "type": "string" + } + ] + }, + "else": { + "items": [ + { + "type": "integer" + } + ] + }, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedItemsOnNonArrayProperty.json b/tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedItemsOnNonArrayProperty.json new file mode 100644 index 00000000..f5c90b20 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedItemsOnNonArrayProperty.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "string", + "unevaluatedItems": false + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AlwaysTrueBranchContributesNoEvaluatedKeys.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AlwaysTrueBranchContributesNoEvaluatedKeys.json new file mode 100644 index 00000000..0a7db1a7 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AlwaysTrueBranchContributesNoEvaluatedKeys.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + true, + { + "properties": { + "kind": { + "type": "string" + } + } + } + ], + "unevaluatedProperties": false +} From caa542607f2b65a419e54a5f6ac5dcb9e713a253 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 14 Aug 2026 14:04:58 +0200 Subject: [PATCH 30/51] Fix uncaught TypeError when additionalProperties:{schema} has a sibling patternProperties AdditionalProperties.phptpl's pattern-exclusion loop referenced an undefined $property instead of the enclosing foreach's $propertyKey. Every key, matching the pattern or not, hit preg_match($pattern, null), which throws a TypeError under PHP 8's strict internal-function typing -- the surrounding catch (\Exception $exception) does not catch TypeError, so construction crashed uncaught for any input on any schema combining additionalProperties: {schema} with a sibling patternProperties, not just producing a wrong validation result. Co-Authored-By: Claude Sonnet 5 --- .../Validator/AdditionalProperties.phptpl | 2 +- tests/Basic/PatternPropertiesTest.php | 45 +++++++++++++++++++ ...nalPropertiesSchemaWithSiblingPattern.json | 11 +++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/Schema/PatternPropertiesTest/AdditionalPropertiesSchemaWithSiblingPattern.json diff --git a/src/Templates/Validator/AdditionalProperties.phptpl b/src/Templates/Validator/AdditionalProperties.phptpl index 150ff401..79958792 100644 --- a/src/Templates/Validator/AdditionalProperties.phptpl +++ b/src/Templates/Validator/AdditionalProperties.phptpl @@ -9,7 +9,7 @@ foreach (array_diff(array_keys($properties), {{ additionalProperties }}) as $propertyKey) { {% if patternProperties %} foreach ({{ patternProperties }} as $pattern) { - if (preg_match($pattern, $property)) { + if (preg_match($pattern, $propertyKey)) { continue 2; } } diff --git a/tests/Basic/PatternPropertiesTest.php b/tests/Basic/PatternPropertiesTest.php index 5422ef6e..b8986f40 100644 --- a/tests/Basic/PatternPropertiesTest.php +++ b/tests/Basic/PatternPropertiesTest.php @@ -5,6 +5,7 @@ namespace PHPModelGenerator\Tests\Basic; use PHPModelGenerator\Exception\ErrorRegistryException; +use PHPModelGenerator\Exception\Object\InvalidAdditionalPropertiesException; use PHPModelGenerator\Exception\Object\InvalidPatternPropertiesException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\GeneratorConfiguration; @@ -277,4 +278,48 @@ public function testObjectTypedPatternPropertiesAreValidated(): void $this->assertSame('Bob', $bob->getName()); $this->assertNull($bob->getAge()); } + + /** + * `additionalProperties: {schema}` combined with a sibling `patternProperties` must route + * a pattern-matching key through the pattern's own subschema and only fall through to + * `additionalProperties` for a key the pattern does not match. Regression guard for + * `AdditionalProperties.phptpl`'s pattern-exclusion loop, which referenced an undefined + * `$property` instead of the enclosing `foreach`'s `$propertyKey` — every key, matching or + * not, hit `preg_match($pattern, null)`, which throws a `TypeError` under PHP 8's strict + * internal-function typing. The `catch (\Exception $exception)` around the per-key + * validation body does not catch `TypeError` (it extends `Error`, not `Exception`), so the + * bug was an uncaught fatal crash on construction for any input, not just a wrong-validator + * result — this is why the fixture has no `properties` block at all: even a key matching + * neither `properties` nor the pattern must be reachable to exercise the fallthrough. + */ + public function testAdditionalPropertiesSchemaValidatesKeysThePatternDoesNotMatch(): void + { + $className = $this->generateClassFromFile('AdditionalPropertiesSchemaWithSiblingPattern.json'); + + // Pattern-matching key validated against the pattern's own string schema. + new $className(['x_foo' => 'hello']); + + // Non-matching key validated against additionalProperties' integer schema. + new $className(['other' => 5]); + + try { + new $className(['x_foo' => 123]); + $this->fail('Expected InvalidPatternPropertiesException for a non-string pattern match'); + } catch (InvalidPatternPropertiesException $exception) { + $this->assertStringContainsString( + "Invalid type for 'pattern property': requires 'string', got 'integer'", + $exception->getMessage(), + ); + } + + try { + new $className(['other' => 'not-an-int']); + $this->fail('Expected InvalidAdditionalPropertiesException for a non-integer fallthrough key'); + } catch (InvalidAdditionalPropertiesException $exception) { + $this->assertStringContainsString( + "Invalid type for 'additional property': requires 'int', got 'string'", + $exception->getMessage(), + ); + } + } } diff --git a/tests/Schema/PatternPropertiesTest/AdditionalPropertiesSchemaWithSiblingPattern.json b/tests/Schema/PatternPropertiesTest/AdditionalPropertiesSchemaWithSiblingPattern.json new file mode 100644 index 00000000..23a4438e --- /dev/null +++ b/tests/Schema/PatternPropertiesTest/AdditionalPropertiesSchemaWithSiblingPattern.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "patternProperties": { + "^x_": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } +} From 50e3fce5f237687dbfb8a3ac64f8f3920da1539f Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 14 Aug 2026 14:05:25 +0200 Subject: [PATCH 31/51] Close remaining Phase 3 coverage gaps for accessor mutation paths Adds the remaining Phase 3 coverage-gap-test-plan items: - Proves the composition cache-hit skip for a setter whose property isn't wired to any _validateComposition_N call, via reflection-based state corruption (a value that would fail revalidation, left untouched to prove the setter never re-validates). - Proves _setUnevaluatedProperty's same-value early return actually skips validation/rollback bookkeeping, not just that revalidating the same valid value happens to succeed, via an error-registry sentinel-identity check. - Direct-exception-mode counterpart to the existing collect-errors test for a remove() that both drops below minProperties and flips a composition branch: only the first applicable exception surfaces, and rollback restores state. - harvestCompositionPropertyNames() recursion into patternProperties and a composition nested inside a branch. - The untyped (bare production-library class) unevaluatedProperties accessor and denyAdditionalProperties() suppressing the accessor entirely. Two items closed with no test needed: 3.2 was already covered by an existing test using the harness's direct-exception-mode default. 3.1's existing test method was renamed and rewritten in place since its docblock and assertions did not actually prove a cache skip. Co-Authored-By: Claude Sonnet 5 --- .../UnevaluatedPropertiesMutabilityTest.php | 25 ++- ...nalPropertiesAccessorPostProcessorTest.php | 49 +++++ ...tedPropertiesAccessorPostProcessorTest.php | 168 ++++++++++++++++++ .../BranchFlipOnRemove.json | 35 ++++ .../NestedPatternOwned.json | 26 +++ .../UntypedUnevaluatedSchema.json | 9 + 6 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NestedPatternOwned.json create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UntypedUnevaluatedSchema.json diff --git a/tests/Basic/UnevaluatedPropertiesMutabilityTest.php b/tests/Basic/UnevaluatedPropertiesMutabilityTest.php index 0ee240be..4decf261 100644 --- a/tests/Basic/UnevaluatedPropertiesMutabilityTest.php +++ b/tests/Basic/UnevaluatedPropertiesMutabilityTest.php @@ -13,6 +13,7 @@ use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use PHPModelGenerator\Tests\Support\ApplicableDrafts; use PHPModelGenerator\Tests\Support\JsonSchemaDraft; +use ReflectionProperty; /** * Verifies that mutations performed after construction re-run the unevaluatedProperties check @@ -151,13 +152,21 @@ public function testAnyOfSoleCovererFlipMakesOrphanedKeyUnevaluated(): void * Nested-branch composition: the parent's `type: object` causes the allOf branches to be * rendered as separate nested classes rather than merged inline. The cache stores the * nested instance reference and the accumulator rebuild calls getEvaluatedProperties() - * on it. Two assertions on the same generated class: + * on it. + * + * Two assertions on the same generated class: * - extra keys are rejected at construction even when the branches live in nested * classes (proves the activation/cache wiring fires for nested-branch composition); - * - a setter on the outer property triggers revalidation, which reads through the - * nested instances correctly and accepts because every key is still covered. + * - `name` is declared directly on the outer schema and by neither branch, so + * `CompositionValidationPostProcessor`'s validator-property map must not wire + * `setName()` to call `_validateComposition_0()` at all — a cache-hit skip, not a + * revalidation that happens to succeed. Proven observably, not just asserted from + * reading the generated code: `foo`'s raw-input value is corrupted via reflection to + * something the branch's own type check would reject, then `setName()` is called. If + * the setter *did* trigger composition revalidation, the corrupted `foo` would surface + * as a validation failure; since it does not, the map correctly excluded `name`. */ - public function testNestedBranchSchemaRejectsExtrasAndRevalidatesThroughNestedInstances(): void + public function testNestedBranchSchemaRejectsExtrasAndSkipsCompositionRevalidationForUnrelatedSetters(): void { $className = $this->generateClassFromFile('AllOfNestedBranches.json', $this->defaultConfig()); @@ -173,11 +182,17 @@ public function testNestedBranchSchemaRejectsExtrasAndRevalidatesThroughNestedIn } $object = new $className(['name' => 'Alice', 'foo' => 'hello', 'bar' => 42]); + + $rawModelDataInputProperty = new ReflectionProperty($object, '_rawModelDataInput'); + $corruptedRawInput = $rawModelDataInputProperty->getValue($object); + $corruptedRawInput['foo'] = 12345; + $rawModelDataInputProperty->setValue($object, $corruptedRawInput); + $object->setName('Bob'); $this->assertSame('Bob', $object->getName()); $this->assertSame( - ['name' => 'Bob', 'foo' => 'hello', 'bar' => 42], + ['name' => 'Bob', 'foo' => 12345, 'bar' => 42], $object->meta()->rawInput(), ); } diff --git a/tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php index 81b5baf6..e2e5bcdb 100644 --- a/tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php @@ -365,6 +365,55 @@ public function testMinPropertiesIsEnforcedWhenRemovingAPatternPropertyKey(): vo $accessor->remove('a1'); } + /** + * Direct-exception-mode counterpart to + * UnevaluatedPropertiesAccessorPostProcessorTest::testRemoveCollectsMinPropertyAndUnevaluatedErrorsTogether: + * the same removal both drops the count below `minProperties` and flips the `anyOf` + * composition (branch 0 requires `p_foo`; removing it leaves only branch 1, which claims + * nothing, orphaning `q_marker`). In collect-errors mode both violations land in one + * registry; in direct-exception mode `RemoveAdditionalProperty.phptpl`'s + * `minPropertyValidator` check runs first — before the unset, before composition + * revalidation, before the post-composition unevaluatedProperties check — so only + * `MinPropertiesException` ever surfaces, never the orphaned-key exception. The rejected + * call's `catch` rolls `_rawModelDataInput` back, so the object's public behavior is + * verified identical to before the failed removal, not just that an exception was thrown. + */ + public function testRemoveThrowsFirstApplicableExceptionDirectlyAndRollsBackOnCompositionMinPropertiesClash(): void + { + $this->addPostProcessor(true); + + $className = $this->generateClassFromFile( + 'BranchFlipOnRemove.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + // Both anyOf branches succeed: branch 0 (kind: string, additionalProperties: integer, + // minProperties: 3) claims p_foo and q_marker; branch 1 (kind: const "X") claims + // nothing. q_marker is credited only through branch 0. + $object = new $className(['kind' => 'X', 'p_foo' => 1, 'q_marker' => 7]); + $accessor = $object->additionalProperties(); + + try { + $accessor->remove('p_foo'); + $this->fail('Expected MinPropertiesException, not an aggregated registry'); + } catch (MinPropertiesException $exception) { + $this->assertSame( + "Provided object for '{$className}' must not contain less than 3 properties", + $exception->getMessage(), + ); + } + + // Rollback discipline: raw input, the additionalProperties bucket, and an unrelated + // setter all behave exactly as they would have if remove() had never been called. + $this->assertSame( + ['kind' => 'X', 'p_foo' => 1, 'q_marker' => 7], + $object->meta()->rawInput(), + ); + $this->assertSame(['q_marker' => 7], $accessor->getAll()); + $object->setKind('X'); + $this->assertSame('X', $object->getKind()); + } + /** * A composition branch declaring `additionalProperties` is decided by keys it never names, * so the setter-side validation cache — which asks whether the mutated keys intersect the diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php index cedf28f9..cbcd2565 100644 --- a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -5,6 +5,8 @@ namespace PHPModelGenerator\Tests\PostProcessor; use DateTime; +use PHPModelGenerator\Accessor\ImmutableUnevaluatedPropertiesAccessor; +use PHPModelGenerator\Accessor\UnevaluatedPropertiesAccessor; use PHPModelGenerator\Exception\ComposedValue\AllOfException; use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\Object\MinPropertiesException; @@ -22,6 +24,7 @@ use PHPModelGenerator\Tests\Support\JsonSchemaDraft; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; +use ReflectionProperty; /** * Exercises UnevaluatedPropertiesAccessorPostProcessor: the unevaluatedProperties() accessor @@ -80,6 +83,53 @@ public function testTypedAccessorRoundTripsCollectedExtras(): void $this->assertSame($accessor, $object->unevaluatedProperties()); } + /** + * `_setUnevaluatedProperty`'s early-return guard + * (`isset($this->_unevaluatedProperties[$key]) && $this->_unevaluatedProperties[$key] === + * $value`) skips validation and rollback bookkeeping entirely when the new value is + * identical to the stored one — not just "revalidates and happens to succeed." Proven + * observably: in collect-errors mode, every non-skip call unconditionally replaces + * `$this->_errorRegistry` with a fresh instance before validating. A sentinel object is + * planted in `_errorRegistry` via reflection; setting the same value must leave that exact + * instance untouched, while setting a different value must replace it — the negative case + * proves the sentinel isn't just always preserved regardless of the guard. + */ + public function testSetSameValueIsANoOpThatSkipsValidationAndRollbackBookkeeping(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(['name' => 'Alice', 'count' => 42]); + $errorRegistryProperty = new ReflectionProperty($object, '_errorRegistry'); + + $sentinel = new ErrorRegistryException(); + $errorRegistryProperty->setValue($object, $sentinel); + + $object->unevaluatedProperties()->set('count', 42); + $this->assertSame( + $sentinel, + $errorRegistryProperty->getValue($object), + 'Same-value set() must not touch _errorRegistry at all', + ); + + // Negative control on a fresh instance: a genuinely different value must replace the + // registry, proving the sentinel above was preserved because the guard skipped the + // call, not because _errorRegistry is untouched regardless of value. + $otherObject = new $className(['name' => 'Alice', 'count' => 42]); + $otherSentinel = new ErrorRegistryException(); + $errorRegistryProperty->setValue($otherObject, $otherSentinel); + + $otherObject->unevaluatedProperties()->set('count', 99); + $this->assertNotSame( + $otherSentinel, + $errorRegistryProperty->getValue($otherObject), + 'A different value must run the full validation path, replacing _errorRegistry', + ); + } + /** * The unevaluatedProperties subschema's runtime validators run on the shim and reject * values that satisfy the PHP type signature but fail a constraint expressed only in @@ -219,6 +269,74 @@ public function testCompanionClassNamingFollowsModelClassName(): void $this->assertTrue($reflection->hasMethod('remove')); } + /** + * A companion class narrows the accessor to the schema's type — `getType() !== null` on + * the validation property gates it. `unevaluatedProperties: {}` (an untyped, empty schema — + * distinct from the boolean `true`, which is a no-op that emits no validator and therefore + * no accessor at all) has no declared type, so no companion is generated: the accessor + * method returns the bare production-library class directly, typed `mixed` throughout. + */ + public function testUntypedUnevaluatedSchemaUsesTheBareProductionLibraryAccessorClass(): void + { + $this->addPostProcessor(); + + $mutableClassName = $this->generateClassFromFile( + 'UntypedUnevaluatedSchema.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + $mutableObject = new $mutableClassName(); + $mutableAccessor = $mutableObject->unevaluatedProperties(); + + $this->assertInstanceOf(UnevaluatedPropertiesAccessor::class, $mutableAccessor); + $this->assertSame(UnevaluatedPropertiesAccessor::class, $mutableAccessor::class); + + // The bare class imposes no type constraint — any value is accepted and round-trips. + $mutableAccessor->set('extra', ['nested' => true]); + $this->assertSame(['nested' => true], $mutableAccessor->get('extra')); + + $immutableClassName = $this->generateClassFromFile( + 'UntypedUnevaluatedSchema.json', + (new GeneratorConfiguration())->setImmutable(true), + ); + $immutableObject = new $immutableClassName(['extra' => 'value']); + + $this->assertSame( + ImmutableUnevaluatedPropertiesAccessor::class, + $immutableObject->unevaluatedProperties()::class, + ); + } + + /** + * `setDenyAdditionalProperties(true)` flips a schema with no explicit `additionalProperties` + * to an implicit `false` — every extra is rejected before the unevaluated phase ever runs, + * so `UnevaluatedPropertiesValidatorFactory` treats the keyword as dead code the same way it + * would for an explicit `additionalProperties: false`. No validator means no backing field + * to expose, so the accessor method itself must not be generated at all. + */ + public function testDenyAdditionalPropertiesSuppressesTheUnevaluatedAccessor(): void + { + $this->addPostProcessor(); + $logger = new RecordingLogger(); + + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false)->setDenyAdditionalProperties(true)->setLogger($logger), + ); + + $this->assertFalse((new ReflectionClass($className))->hasMethod('unevaluatedProperties')); + $this->assertTrue( + $this->hasLogEntry( + $logger->getEntries(), + 'warning', + 'unevaluatedProperties on {class} is dead code — {reason}', + [ + 'reason' => 'denyAdditionalProperties() flips missing additionalProperties to' + . ' false, rejecting every extra before the unevaluated phase runs', + ], + ), + ); + } + /** * Immutable models receive only the read side of the accessor. When the schema is typed, * the companion class is still emitted but omits the mutator methods entirely. @@ -385,6 +503,56 @@ public function testSetRejectsKeysDeclaredInCompositionBranch(): void } } + /** + * `harvestCompositionPropertyNames()` must recurse into two shapes beyond a branch's own + * flat `properties`: a branch's `patternProperties` (matched by regex, not by exact name — + * `b_foo`, harvested from branch 0) and a composition nested *inside* a branch (`nested`, + * declared by a `oneOf` inside branch 1). The two shapes sit in separate `allOf` branches + * rather than combined in one to keep the generated nested-class chain shallow enough for + * Windows filename-length limits. A genuinely unclaimed key still passes as a control, + * proving the guard isn't just rejecting everything. + */ + public function testSetRejectsKeysOwnedThroughNestedCompositionAndPatternProperties(): void + { + $this->addPostProcessor(); + // originalClassNames: true — the fixture's two-level branch nesting combined with the + // default uniqid-multiplying class-name generator exceeds Windows' path-length limit. + $className = $this->generateClassFromFile( + 'NestedPatternOwned.json', + (new GeneratorConfiguration())->setImmutable(false), + originalClassNames: true, + ); + + $object = new $className(); + $accessor = $object->unevaluatedProperties(); + + try { + // 'b_foo' is owned by the branch's own patternProperties, matched by regex. + $accessor->set('b_foo', 42); + $this->fail('Expected RegularPropertyAsUnevaluatedPropertyException for the pattern-owned key'); + } catch (RegularPropertyAsUnevaluatedPropertyException $exception) { + $this->assertSame( + "Could not add regular property 'b_foo' as an unevaluated property of object '{$className}'", + $exception->getMessage(), + ); + } + + try { + // 'nested' is owned by a oneOf composition nested inside the allOf branch. + $accessor->set('nested', 42); + $this->fail('Expected RegularPropertyAsUnevaluatedPropertyException for the nested-composition-owned key'); + } catch (RegularPropertyAsUnevaluatedPropertyException $exception) { + $this->assertSame( + "Could not add regular property 'nested' as an unevaluated property of object '{$className}'", + $exception->getMessage(), + ); + } + + // Control: a key owned by nothing is genuinely unevaluated and must be accepted. + $accessor->set('truly_unevaluated', 42); + $this->assertSame(42, $accessor->get('truly_unevaluated')); + } + /** * Collect-errors mode: a failed set() leaves the error registry populated. The shim's * registry reset isolates each setter call so a caller that catches the failure can diff --git a/tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json b/tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json new file mode 100644 index 00000000..b190b6f1 --- /dev/null +++ b/tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json @@ -0,0 +1,35 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "patternProperties": { + "^p_": { + "type": "integer" + } + }, + "minProperties": 3, + "anyOf": [ + { + "minProperties": 3, + "properties": { + "kind": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } + }, + { + "properties": { + "kind": { + "const": "X" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NestedPatternOwned.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NestedPatternOwned.json new file mode 100644 index 00000000..4855f3a0 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NestedPatternOwned.json @@ -0,0 +1,26 @@ +{ + "type": "object", + "unevaluatedProperties": { + "type": "integer" + }, + "allOf": [ + { + "patternProperties": { + "^b_": { + "type": "string" + } + } + }, + { + "oneOf": [ + { + "properties": { + "nested": { + "type": "string" + } + } + } + ] + } + ] +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UntypedUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UntypedUnevaluatedSchema.json new file mode 100644 index 00000000..79a881b0 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UntypedUnevaluatedSchema.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": {} +} From 824f964235d1bba37a5807ce774fab693f1f6e90 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 14 Aug 2026 16:02:58 +0200 Subject: [PATCH 32/51] Close remaining Phase 3 coverage gaps for accessor mutation paths Adds regression coverage for harvestCompositionPropertyNames()'s if/then/else recursion, found by cross-referencing a fresh coverage report against the earlier gap analysis: allOf/anyOf/oneOf and if/then/else are two separate recursive code paths in the harvester, and the existing nested-composition test only exercised oneOf. Co-Authored-By: Claude Sonnet 5 --- ...tedPropertiesAccessorPostProcessorTest.php | 29 ++++++++++++++----- .../NestedPatternOwned.json | 16 ++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php index cbcd2565..491a1125 100644 --- a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -506,17 +506,20 @@ public function testSetRejectsKeysDeclaredInCompositionBranch(): void /** * `harvestCompositionPropertyNames()` must recurse into two shapes beyond a branch's own * flat `properties`: a branch's `patternProperties` (matched by regex, not by exact name — - * `b_foo`, harvested from branch 0) and a composition nested *inside* a branch (`nested`, - * declared by a `oneOf` inside branch 1). The two shapes sit in separate `allOf` branches - * rather than combined in one to keep the generated nested-class chain shallow enough for - * Windows filename-length limits. A genuinely unclaimed key still passes as a control, - * proving the guard isn't just rejecting everything. + * `b_foo`, harvested from branch 0), a composition nested *inside* a branch via `oneOf` + * (`nested`, declared by branch 1), and the same nested recursion via `if`/`then`/`else` + * (`onlyWhenA`, declared inside branch 2's `then`) — `allOf`/`anyOf`/`oneOf` and + * `if`/`then`/`else` are two separate recursive code paths in the harvester and neither + * exercises the other. The three shapes sit in separate `allOf` branches rather than + * combined in one to keep the generated nested-class chain shallow enough for Windows + * filename-length limits. A genuinely unclaimed key still passes as a control, proving the + * guard isn't just rejecting everything. */ public function testSetRejectsKeysOwnedThroughNestedCompositionAndPatternProperties(): void { $this->addPostProcessor(); - // originalClassNames: true — the fixture's two-level branch nesting combined with the - // default uniqid-multiplying class-name generator exceeds Windows' path-length limit. + // originalClassNames: true — the fixture's branch nesting combined with the default + // uniqid-multiplying class-name generator exceeds Windows' path-length limit. $className = $this->generateClassFromFile( 'NestedPatternOwned.json', (new GeneratorConfiguration())->setImmutable(false), @@ -548,6 +551,18 @@ public function testSetRejectsKeysOwnedThroughNestedCompositionAndPatternPropert ); } + try { + // 'onlyWhenA' is owned by the then-branch of an if/then/else nested inside the + // allOf branch — the harvester's separate if/then/else recursion, not oneOf's. + $accessor->set('onlyWhenA', 42); + $this->fail('Expected RegularPropertyAsUnevaluatedPropertyException for the then-branch-owned key'); + } catch (RegularPropertyAsUnevaluatedPropertyException $exception) { + $this->assertSame( + "Could not add regular property 'onlyWhenA' as an unevaluated property of object '{$className}'", + $exception->getMessage(), + ); + } + // Control: a key owned by nothing is genuinely unevaluated and must be accepted. $accessor->set('truly_unevaluated', 42); $this->assertSame(42, $accessor->get('truly_unevaluated')); diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NestedPatternOwned.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NestedPatternOwned.json index 4855f3a0..9419d3ad 100644 --- a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NestedPatternOwned.json +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NestedPatternOwned.json @@ -21,6 +21,22 @@ } } ] + }, + { + "if": { + "properties": { + "kind": { + "const": "a" + } + } + }, + "then": { + "properties": { + "onlyWhenA": { + "type": "string" + } + } + } } ] } From a8e7a9e9f1a486074292c0866e55b22bbd371a75 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Sun, 16 Aug 2026 23:03:21 +0200 Subject: [PATCH 33/51] Close remaining Phase 3 coverage gaps for accessor mutation paths Cross-referenced a fresh --coverage-clover run against the original gap analysis (57 uncovered lines) rather than trusting behavioral verification alone. Closes two of the four remaining lines: - UnevaluatedItemsValidatorFactory's dead-code classifier falls through to "not dead code" for items: null (not a valid items value) rather than misclassifying it as one of the three recognised dead-code shapes. Also documents a genuine, previously-undiscovered bug found while tracing the other two lines: a composition branch's own unevaluatedProperties never sees properties the enclosing schema (or a sibling branch) declares -- only the reverse direction (branch claims propagating up to an outer unevaluatedProperties) was ever built. `{properties: {name}, allOf: [{unevaluatedProperties: false}]}` wrongly rejects {name: "Alice"} even though name is declared and validated by the enclosing schema. Root cause and revisit trigger are in implementation-plan.md item 20; surfaced here via markTestIncomplete since the fix touches how a branch's nested class computes its evaluated set, not a single call site. Co-Authored-By: Claude Sonnet 5 --- tests/Basic/UnevaluatedItemsValidatorTest.php | 24 +++++++++ .../UnevaluatedPropertiesValidatorTest.php | 53 +++++++++++++++++++ .../ItemsNeitherBooleanTupleNorSchema.json | 10 ++++ ...tedPropertiesIgnoresOuterDeclarations.json | 13 +++++ 4 files changed, 100 insertions(+) create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/ItemsNeitherBooleanTupleNorSchema.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index ef0808c3..62ba4c52 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -436,6 +436,30 @@ public function testUnevaluatedItemsOnNonArrayPropertyIsSilentlyIgnored(): void } } + /** + * `items: null` is not a valid `items` value (the keyword must be a boolean or a schema), + * but the dead-code classifier must still degrade gracefully rather than misclassifying it + * as one of the three recognised dead-code shapes (`items: false`, tuple-form with + * `additionalItems: false`, or `items: {schema}`). `null` claims nothing, so + * `unevaluatedItems: false` correctly falls through to "not dead code" and rejects every + * index — proving the classifier's final fallback branch is a safe default, not a silent + * misclassification that would let a malformed `items` value slip through unvalidated. + */ + public function testItemsNeitherBooleanTupleNorSchemaFallsThroughToNotDeadCode(): void + { + $className = $this->generateClassFromFile('ItemsNeitherBooleanTupleNorSchema.json'); + + try { + new $className(['tags' => ['a', 'b']]); + $this->fail('Expected UnevaluatedItemsException since items: null claims no indices'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#0, #1]", + $exception->getMessage(), + ); + } + } + /** * Composition branches contribute their evaluated indices to a sibling unevaluatedItems * accumulator. The fixture has two tuple-form items branches under allOf: branch 1 covers diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index b450fee4..cbd20166 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -470,6 +470,59 @@ public function testBranchLevelUnevaluatedTrueClaimsEveryKeyForTheOuterAccumulat $this->assertSame(['foo' => 'x', 'bar' => 5], $accepted->meta()->rawInput()); } + /** + * Deferred bug, found while auditing line-coverage on `UnevaluatedPropertiesPostProcessor`: + * a composition branch that declares `unevaluatedProperties` with no matching keyword on + * the enclosing schema must still see every property the enclosing schema (or a sibling + * branch) declares — `allOf` does not introduce a new evaluation-context boundary, so per + * spec a branch-level `unevaluatedProperties` is evaluated against the *same* annotation + * set as an outer-level one would be. + * + * The branch here renders as its own nested class (any branch declaring + * `unevaluatedProperties` routes through the object-instantiation path, even with no other + * keywords of its own). That nested class's `_executePostCompositionValidators()` calls + * `collectUnevaluatedKeys($modelData, [], [], [])` — an empty declared-names list, because + * the branch itself declares nothing. It has no visibility into `name`, which the + * *enclosing* schema's own `properties` declares and validates. The construction below + * should therefore succeed (`name` is evaluated by the enclosing schema), but instead + * throws `AllOfException` wrapping an `UnevaluatedPropertiesException` naming `name` as + * unevaluated — confirmed independent of whether the branch has its own `properties` + * (verified by hand against a variant with `properties: {foo}` added to the branch: the + * *same* enclosing-declared `name` is still wrongly rejected). + * + * This is the mirror image of `testBranchLevelAdditionalPropertiesFeedsEvaluatedSetThroughComposition` + * and friends above: those prove a branch's claims propagate *up* to an outer + * `unevaluatedProperties`. This is the reverse direction — an outer (or sibling-branch) + * declaration propagating *down* into a branch's own `unevaluatedProperties` — and per + * `docs/source/combinedSchemas/allOf.rst`'s "Property and item evaluation propagation" + * section (which only documents the up-propagation direction), the down direction was + * never built. + * + * Deferred: the fix touches how a composition branch's own nested class computes its + * evaluated set, not a single call site — it needs to receive (or otherwise gain + * visibility into) the declared names of the enclosing schema and any sibling branches, + * which the current architecture does not plumb through to a branch's own generated class + * at all. Tracked in implementation-plan.md's post-implementation-review list. + */ + public function testBranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations(): void + { + $this->markTestIncomplete( + 'Bug: a composition branch\'s own unevaluatedProperties does not see properties the ' + . 'enclosing schema (or a sibling branch) declares — the branch\'s nested class has ' + . 'no visibility into names declared outside itself. See the class docblock above ' + . 'for the full analysis; tracked in implementation-plan.md.', + ); + + // @phpstan-ignore-next-line dead code — unreachable until the fix lands + $className = $this->generateClassFromFile('BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json'); + + // 'name' is declared and validated by the enclosing schema's own `properties`, not by + // the branch — the branch's `unevaluatedProperties: false` must still treat it as + // evaluated, since allOf shares one evaluation context with its enclosing schema. + $accepted = new $className(['name' => 'Alice']); + $this->assertSame(['name' => 'Alice'], $accepted->meta()->rawInput()); + } + /** * Keys matched by a successful branch's `patternProperties` (with passing values) are * evaluated by that branch and must be credited to the outer accumulator — also when the diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ItemsNeitherBooleanTupleNorSchema.json b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsNeitherBooleanTupleNorSchema.json new file mode 100644 index 00000000..f63010bb --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsNeitherBooleanTupleNorSchema.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": null, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json new file mode 100644 index 00000000..1a67ed5a --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "unevaluatedProperties": false + } + ] +} From e84ff69d67ac326ef21bd9beb8c9ee97dc51927a Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Sun, 16 Aug 2026 23:41:18 +0200 Subject: [PATCH 34/51] Fix two distinct bugs in array-side composition nested inside composition Post-implementation-review items 13 and 17 both described "composition nested inside a composition branch on an array-typed property" but turned out to be two independent bugs at different layers, not one: - Item 17 (activation-detection gap): UnevaluatedPropertiesPostProcessor never recursed into a branch's own nested composition when the branch is array-typed (no nested Schema to recurse through via getNestedSchema(), unlike object-typed branches). Result: a fatal "Error: Call to undefined method ...::collectUnevaluatedIndices()" -- the innermost branch's unevaluatedItems validator rendered and called into CompositionEvaluationTrait, but the trait was never added to the class. Fixed by recursing through the branch's wrapped property's own validators, mirroring how activateValidatorsInBranch() already walks this exact structure for the activation step itself. - Item 13 (annotation-aggregation gap): once activation is correctly triggered, a branch that is itself purely a nested composition (no direct items/contains of its own) never had its nested composition's correctly-tracked claimed indices read back into its own tracked slot, so the outer unevaluatedItems: false over-rejected every index instead of just the ones actually left over. Fixed by CompositionPropertyDecorator::getNestedCompositionSlotKeys(), unioned into the branch's evaluated-index set in ComposedItem.phptpl. Both fixtures were re-tested against each fix in isolation to confirm the two bugs are independent, not the same defect surfacing twice. Co-Authored-By: Claude Sonnet 5 --- .../Property/CompositionPropertyDecorator.php | 40 +++++++ .../UnevaluatedPropertiesPostProcessor.php | 53 ++++++++- src/Templates/Validator/ComposedItem.phptpl | 8 +- tests/Basic/UnevaluatedItemsValidatorTest.php | 101 ++++++++++++++---- ...dTwoLevelsDeepCreditsOuterAccumulator.json | 25 +++++ 5 files changed, 201 insertions(+), 26 deletions(-) create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/CompositionNestedTwoLevelsDeepCreditsOuterAccumulator.json diff --git a/src/Model/Property/CompositionPropertyDecorator.php b/src/Model/Property/CompositionPropertyDecorator.php index 4a1be3b6..083b648b 100644 --- a/src/Model/Property/CompositionPropertyDecorator.php +++ b/src/Model/Property/CompositionPropertyDecorator.php @@ -7,6 +7,7 @@ use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\SchemaDefinition\ResolvedDefinitionsCollection; +use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; use PHPModelGenerator\Model\Validator\InstanceOfValidator; use PHPModelGenerator\Model\Validator\PropertyValidatorInterface; use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; @@ -278,6 +279,45 @@ public function branchIsArrayKind(): bool return $hasArrayApplicator && !$hasObjectApplicator; } + /** + * Slot keys of composition validators nested directly inside this branch's own JSON (the + * branch is itself `{allOf: [...]}`, `{oneOf: [...]}`, etc.) that also carry evaluation + * tracking. An array-typed branch never gets its own nested `Schema` (unlike an + * object-typed one, which always routes through `processSchema()`), so a composition + * nested inside it renders as a further validator call on the same wrapped property + * rather than inside a separate class — its claimed indices are reachable only by reading + * back `_compositionAnnotated[]` at runtime, not by recursing into a nested + * `Schema`'s own composed properties the way an object-typed branch's nesting would allow. + * The composition template unions each returned slot's tracked indices into this branch's + * own evaluated set. + * + * @return string[] + */ + public function getNestedCompositionSlotKeys(): array + { + $slotKeys = []; + + foreach ($this->getWrappedProperty()->getOrderedValidators() as $validator) { + if ($validator instanceof AbstractComposedPropertyValidator && $validator->getSlotKey() !== null) { + $slotKeys[] = $validator->getSlotKey(); + } + } + + return $slotKeys; + } + + /** + * True when the composition template must build an evaluated-index set for this branch — + * either from the branch's own direct array applicators ({@see branchIsArrayKind()}) or + * from a composition nested inside the branch ({@see getNestedCompositionSlotKeys()}). + * Combines both into one boolean because the template engine's conditional expressions do + * not support parenthesised boolean grouping. + */ + public function needsEvaluatedIndexAggregation(): bool + { + return $this->branchIsArrayKind() || $this->getNestedCompositionSlotKeys() !== []; + } + /** * @inheritdoc * diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php index dc9a4242..4ccc54bf 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -225,8 +225,18 @@ private function activateArrayPropertyTracking(Schema $schema): void /** * True when any composition validator directly on the property carries a branch declaring - * `unevaluatedItems`. Such a branch's validator writes and reads `_evaluatedItemIndices`, so - * the field must be declared even though the array property itself has no unevaluatedItems. + * `unevaluatedItems` — either directly in the branch's own JSON, or nested one or more + * levels deeper inside a composition the branch itself carries. Such a branch's validator + * writes and reads `_evaluatedItemIndices`, so the field must be declared even though the + * array property itself has no unevaluatedItems. + * + * An array-typed branch never gets its own nested `Schema` (unlike an object-typed branch, + * which always routes through `processSchema()`), so a further composition keyword nested + * inside the branch's own JSON — e.g. the branch is itself `{oneOf: [{unevaluatedItems: + * ...}]}` — has no `Schema` object for `getNestedSchema()` to recurse into. Recursing + * through the branch's wrapped property's own validators instead mirrors how + * `activateValidatorsInBranch()` already walks this exact structure for the activation step + * itself. */ private function propertyHasBranchUnevaluatedItems(PropertyInterface $property): bool { @@ -239,6 +249,13 @@ private function propertyHasBranchUnevaluatedItems(PropertyInterface $property): if (array_key_exists('unevaluatedItems', $composedProperty->getBranchSchema()->getJson())) { return true; } + + if ( + $composedProperty->getNestedSchema() === null + && $this->propertyHasBranchUnevaluatedItems($composedProperty->getWrappedProperty()) + ) { + return true; + } } } @@ -481,8 +498,21 @@ private function needsActivation(Schema $schema, array &$seen): bool /** * True when any composition validator in the given list carries a branch that declares an - * unevaluated keyword — either in the branch-level JSON or in a nested schema the branch - * produces. Shared by the schema-level (base validators) and property-level checks. + * unevaluated keyword — either in the branch-level JSON, in a nested schema the branch + * produces, or in a further composition nested inside the branch's own JSON. Shared by the + * schema-level (base validators) and property-level checks. + * + * An object-typed branch always gets its own nested `Schema` (routed through + * `processSchema()`), so a further composition nested inside it is reached by recursing + * into that `Schema` via `needsActivation()`. An array-typed branch never gets one, so a + * branch shaped like `{oneOf: [{unevaluatedItems: ...}]}` — a composition nested directly + * inside another branch, with no intervening object type — has no `Schema` object for that + * recursion to reach. Recursing through the branch's wrapped property's own validators + * instead mirrors how `activateValidatorsInBranch()` already walks this exact structure for + * the activation step itself. Without this, the branch's own nested validator still renders + * (schema processing is not gated by this detection), but the class it renders into never + * receives `CompositionEvaluationTrait` or the `_evaluatedItemIndices` field the rendered + * code calls into — a fatal `Error: Call to undefined method`, not a silent gap. * * @param iterable $validators * @param array $seen @@ -506,7 +536,20 @@ private function compositionValidatorsNeedActivation(iterable $validators, array $nestedSchema = $composedProperty->getNestedSchema(); - if ($nestedSchema !== null && $this->needsActivation($nestedSchema, $seen)) { + if ($nestedSchema !== null) { + if ($this->needsActivation($nestedSchema, $seen)) { + return true; + } + + continue; + } + + if ( + $this->compositionValidatorsNeedActivation( + $composedProperty->getWrappedProperty()->getOrderedValidators(), + $seen, + ) + ) { return true; } } diff --git a/src/Templates/Validator/ComposedItem.phptpl b/src/Templates/Validator/ComposedItem.phptpl index afe5a39b..751cdf41 100644 --- a/src/Templates/Validator/ComposedItem.phptpl +++ b/src/Templates/Validator/ComposedItem.phptpl @@ -99,7 +99,7 @@ {{ viewHelper.indent(renderComposedItemBody(compositionProperty, false, hasModifiedValuesMethod, modifiedValuesMethod, postPropose), 2) }} {% endif %} - {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and compositionProperty.branchIsArrayKind() %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and compositionProperty.needsEvaluatedIndexAggregation() %} if ($shouldTrack) { {% if compositionProperty.branchHasItemsSchema() %} $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); @@ -121,6 +121,12 @@ {% if compositionProperty.branchHasContains() %} $compositionEvaluatedIndices += $branchContainsMatches; {% endif %} + {% foreach compositionProperty.getNestedCompositionSlotKeys() as nestedCompositionSlotKey %} + // A composition nested directly inside this branch's own JSON (no + // intervening object type, so no nested Schema of its own) tracked its + // claimed indices under its own slot — union them into this branch's set. + $compositionEvaluatedIndices += $this->_compositionAnnotated['{{ nestedCompositionSlotKey }}'] ?? []; + {% endforeach %} } {% endif %} {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 62ba4c52..34221b5b 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -689,29 +689,90 @@ public function testSelfReferencingArrayCompositionDoesNotRecurseIndefinitely(): } /** - * A composition nested inside a composition branch on an array-typed property is - * silently dropped during generation — not just its unevaluatedItems keyword, the - * entire nested composition and everything inside it. Confirmed independent of - * unevaluatedItems: the same fixture shape with a plain `minItems` in place of - * `unevaluatedItems` inside the inner branch produces identical output (the inner - * `oneOf` never renders at all, no error, no warning). The object-typed equivalent - * (`{type: object, allOf: [{oneOf: [{minProperties: ...}]}]}`) generates correctly — - * each composition level gets its own nested class — so the gap is specific to array - * properties, which never carry a nested schema for a branch's own composition to - * attach to. This is a pre-existing generation-pipeline gap, not introduced by the - * unevaluatedItems work; fixing it belongs to the composition rendering path, not to - * this feature's post processor or validators (deferred bug; tracked in the - * implementation plan). + * A composition (`oneOf`) nested inside a composition branch (`allOf`) on an array-typed + * property, with `unevaluatedItems` declared only on the innermost branch and nowhere at + * the outer property level, used to crash construction with `Error: Call to undefined + * method ...::collectUnevaluatedIndices()` rather than validate or reject cleanly. + * + * Root cause: `UnevaluatedPropertiesPostProcessor::compositionValidatorsNeedActivation()` + * (and its property-level sibling `propertyHasBranchUnevaluatedItems()`) checked a + * branch's own JSON directly and recursed into a branch's nested `Schema` — but an + * array-typed branch never gets its own nested `Schema` (unlike an object-typed one, + * which always routes through `processSchema()`), so a further composition nested + * directly inside another branch's own JSON (no intervening object type) was invisible to + * the detection walk. The schema-processing step that renders the innermost branch's own + * `unevaluatedItems` validator is not gated by that detection at all, so the call to + * `collectUnevaluatedIndices()` (from `CompositionEvaluationTrait`) rendered into the + * class regardless — but the trait itself, and the `_evaluatedItemIndices` field, were + * never added, since the post processor's `process()` short-circuited on the (wrongly) + * negative detection result before reaching either. + * + * Fixed by recursing through the branch's wrapped property's own validators — mirroring + * how `activateValidatorsInBranch()` already walks this exact structure for the + * activation step itself — whenever a composed property's own nested `Schema` is null. */ public function testCompositionNestedInsideArrayBranchIsNotSilentlyDropped(): void { - $this->markTestIncomplete( - 'A composition nested inside a composition branch on an array-typed property ' - . 'is silently dropped during generation (confirmed independent of ' - . 'unevaluatedItems — a plain minItems in the same position is dropped too). ' - . 'The object-typed equivalent generates correctly, so the gap is array-specific ' - . '(deferred bug; tracked in the implementation plan).', - ); + $className = $this->generateClassFromFile('NestedCompositionInsideArrayBranchSilentlyDropped.json'); + + $accepted = new $className(['tags' => ['a', 'b']]); + $this->assertSame(['a', 'b'], $accepted->getTags()); + + try { + new $className(['tags' => [1, 2]]); + $this->fail('Expected the innermost branch\'s unevaluatedItems: {type: string} to reject integers'); + } catch (AllOfException $exception) { + $this->assertStringContainsString( + "Invalid unevaluated items in array 'tags'", + $exception->getMessage(), + ); + } + } + + /** + * A composition (`allOf`) nested two levels inside another composition (`allOf` inside + * `allOf`) on an array property, with `unevaluatedItems: false` declared at the *outer* + * property level (not inside a branch). The innermost branch's own tuple `items` claims + * indices 0-1; that claim must reach the outer `unevaluatedItems` accumulator through both + * levels of nesting, not just the first. + * + * Root cause (distinct from `testCompositionNestedInsideArrayBranchIsNotSilentlyDropped` + * above — that one is an activation-detection gap causing a fatal error; this one is a + * downstream aggregation gap once activation is already correctly triggered): + * `ComposedItem.phptpl`'s per-branch index-set block only populated + * `$compositionEvaluatedIndices` from a branch's own *direct* `items`/`additionalItems`/ + * `contains` shape (`CompositionPropertyDecorator::branchIsArrayKind()`). A branch that is + * itself purely a nested composition (`{allOf: [{items: [...]}]}`, with no array + * applicator of its own) fails that check, so the block never ran for it — its own nested + * composition's tracked slot (`_compositionAnnotated['tags_1']`) was computed correctly + * but never read back into the enclosing branch's own slot (`_compositionAnnotated + * ['tags_0']`), which the outer `unevaluatedItems: false` reads. Before the fix, all three + * indices were reported unevaluated instead of just index 2. + * + * Fixed by `CompositionPropertyDecorator::getNestedCompositionSlotKeys()`: the indices of + * any composition validator nested directly inside a branch's own JSON (found by walking + * the branch's wrapped property's own validators, the same way the activation step already + * does) get unioned into the branch's own evaluated set. + */ + public function testCompositionNestedTwoLevelsDeepCreditsOuterAccumulator(): void + { + $className = $this->generateClassFromFile('CompositionNestedTwoLevelsDeepCreditsOuterAccumulator.json'); + + // Exactly the tuple length: both indices covered by the innermost branch — accept. + $accepted = new $className(['tags' => ['a', 'b']]); + $this->assertSame(['a', 'b'], $accepted->getTags()); + + // A trailing element past the tuple is not covered by any level of the nesting. + try { + new $className(['tags' => ['a', 'b', 'c']]); + $this->fail('Expected UnevaluatedItemsException for the index past the nested tuple'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#2]", + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } } /** diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/CompositionNestedTwoLevelsDeepCreditsOuterAccumulator.json b/tests/Schema/UnevaluatedItemsValidatorTest/CompositionNestedTwoLevelsDeepCreditsOuterAccumulator.json new file mode 100644 index 00000000..8df5b836 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/CompositionNestedTwoLevelsDeepCreditsOuterAccumulator.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "allOf": [ + { + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + ] + } + ], + "unevaluatedItems": false + } + } +} From e547904a284688ad04aa5d4ec279be16eb998cd5 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Wed, 19 Aug 2026 23:42:09 +0200 Subject: [PATCH 35/51] Fix vacuous-branch warning gaps for not and multi-type object candidacy warnIfVacuousBranch() never reached a `not: {}` branch because NotValidatorFactory never forwarded which branch index inherited its type from the parent - inheritPropertyType() now returns that index set (tuple return replaces the easy-to-forget by-ref out-param) instead of silently dropping it for one composition keyword. Also fixes TypeCheck::buildNegatedJsonSchemaTypeCheck() wrongly rejecting an empty JSON object `{}` for a multi-type object candidate whenever its sibling type wasn't `array` (json_decode can't tell `{}` from `[]`, and the existing 'array'-side carve-out had no 'object'-side mirror). Both found via code review; each ships with a red-then-green regression test. Co-Authored-By: Claude Sonnet 5 --- .../AbstractCompositionValidatorFactory.php | 37 ++++- .../Composition/AllOfValidatorFactory.php | 7 +- .../Composition/AnyOfValidatorFactory.php | 7 +- .../Composition/IfValidatorFactory.php | 2 +- .../Composition/NotValidatorFactory.php | 7 +- .../Composition/OneOfValidatorFactory.php | 7 +- .../Validator/MultiTypeCheckValidator.php | 28 ++-- .../ReflectionTypeCheckValidator.php | 20 +-- src/PropertyProcessor/PropertyFactory.php | 39 ++++- src/SchemaProcessor/SchemaProcessor.php | 23 ++- src/Utils/TypeCheck.php | 45 ++--- tests/AbstractPHPModelGeneratorTestCase.php | 102 ++++++++++-- .../UnevaluatedPropertiesValidatorTest.php | 156 +++++++++++++----- tests/Issues/Issue/Issue72Test.php | 17 ++ tests/Objects/MultiTypePropertyTest.php | 125 +++++++++++++- .../Issues/72/VacuousBranchWarning.json | 4 + ...MultiTypePropertyWithOneOfArrayBranch.json | 29 ++++ ...pePropertyWithOneOfObjectStringBranch.json | 25 +++ .../AuthorDeclaredTypeBranchStaysSilent.json | 13 ++ .../ExternalSchemaPlaceholderBranch.json | 14 ++ .../definitionsOnlyPlaceholderTarget.json | 7 + 21 files changed, 594 insertions(+), 120 deletions(-) create mode 100644 tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfArrayBranch.json create mode 100644 tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfObjectStringBranch.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/AuthorDeclaredTypeBranchStaysSilent.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalSchemaPlaceholderBranch.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest_external/definitionsOnlyPlaceholderTarget.json diff --git a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php index b82ea814..d4e09afb 100644 --- a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php @@ -341,7 +341,13 @@ protected function checkForFilterInBranches( /** * Build composition sub-properties for the current keyword's branches. * - * @param bool $merged Whether to suppress CompositionTypeHintDecorators for object branches. + * @param bool $merged Whether to suppress + * CompositionTypeHintDecorators for + * object branches. + * @param array $injectedTypeBranchIndices Branch indices whose 'type' was + * inherited rather than author-declared + * (see inheritPropertyType()) - excluded + * from the vacuous-branch check below. * * @return CompositionPropertyDecorator[] * @@ -353,6 +359,7 @@ protected function getCompositionProperties( PropertyInterface $property, JsonSchema $propertySchema, bool $merged, + array $injectedTypeBranchIndices = [], ): array { $propertyFactory = new PropertyFactory(); $compositionProperties = []; @@ -425,6 +432,12 @@ protected function getCompositionProperties( if ($compositionProperty->isResolved()) { $resolvedBranchJson = $compositionProperty->getJsonSchema()->getJson(); + // An inherited 'type' isn't an author-declared constraint - strip it so + // vacuousness is judged on what the branch actually declares. + if (isset($injectedTypeBranchIndices[$index])) { + unset($resolvedBranchJson['type']); + } + $this->warnIfVacuousBranch($schemaProcessor, $property, $index + 1, $resolvedBranchJson, $draft); } @@ -529,22 +542,32 @@ protected function createAlwaysTrueBranchProperty( /** * Inherit a parent-level type into composition branches that declare no type. + * + * @return array{0: JsonSchema, 1: array} The (possibly type-injected) schema, and + * the zero-based indices of branches whose 'type' this call injected rather than + * found already declared - consumed by getCompositionProperties()'s vacuous-branch + * check. Populated for 'not' too (index 0, once the caller wraps its single schema + * into an array). Always empty for 'if', which never reaches + * getCompositionProperties(). */ protected function inheritPropertyType( SchemaProcessor $schemaProcessor, PropertyInterface $property, JsonSchema $propertySchema, - ): JsonSchema { + ): array { + $injectedTypeBranchIndices = []; + $json = $propertySchema->getJson(); if (!isset($json['type'])) { - return $propertySchema; + return [$propertySchema, $injectedTypeBranchIndices]; } switch ($this->key) { case 'not': if (!isset($json[$this->key]['type'])) { $json[$this->key]['type'] = $json['type']; + $injectedTypeBranchIndices[0] = true; if ($json['type'] === 'object') { $this->warnIfInjectedObjectTypeConflictsWithEnumOrConst( @@ -557,11 +580,15 @@ protected function inheritPropertyType( } break; case 'if': - return $this->inheritIfPropertyType($schemaProcessor, $property, $propertySchema->withJson($json)); + return [ + $this->inheritIfPropertyType($schemaProcessor, $property, $propertySchema->withJson($json)), + $injectedTypeBranchIndices, + ]; default: foreach ($json[$this->key] as $index => &$composedElement) { if (!is_bool($composedElement) && !isset($composedElement['type'])) { $composedElement['type'] = $json['type']; + $injectedTypeBranchIndices[$index] = true; if ($json['type'] === 'object') { $this->warnIfInjectedObjectTypeConflictsWithEnumOrConst( @@ -575,7 +602,7 @@ protected function inheritPropertyType( } } - return $propertySchema->withJson($json); + return [$propertySchema->withJson($json), $injectedTypeBranchIndices]; } /** diff --git a/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php index ba120894..3dda402e 100644 --- a/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php @@ -38,7 +38,11 @@ public function modify( } $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); - $propertySchema = $this->inheritPropertyType($schemaProcessor, $property, $propertySchema); + [$propertySchema, $injectedTypeBranchIndices] = $this->inheritPropertyType( + $schemaProcessor, + $property, + $propertySchema, + ); $this->checkForFilterInBranches($property, $propertySchema); $wrappedSchema = $propertySchema->withJson([ @@ -53,6 +57,7 @@ public function modify( $property, $wrappedSchema, true, + $injectedTypeBranchIndices, ); $resolvedCompositions = 0; diff --git a/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php index 18e4512f..b6cff559 100644 --- a/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php @@ -39,7 +39,11 @@ public function modify( } $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); - $propertySchema = $this->inheritPropertyType($schemaProcessor, $property, $propertySchema); + [$propertySchema, $injectedTypeBranchIndices] = $this->inheritPropertyType( + $schemaProcessor, + $property, + $propertySchema, + ); $this->checkForFilterInBranches($property, $propertySchema); $onlyForDefinedValues = !($property instanceof BaseProperty) @@ -58,6 +62,7 @@ public function modify( $property, $wrappedSchema, true, + $injectedTypeBranchIndices, ); $resolvedCompositions = 0; diff --git a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php index a5e89715..babef878 100644 --- a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php @@ -62,7 +62,7 @@ public function modify( // that sub-schemas that inherit 'object' are correctly recognised as object-typed. // Object-typed sub-schemas create nested schemas whose properties are processed // independently and are not subject to ComposedItem $value reset. - $propertySchema = $this->inheritPropertyType($schemaProcessor, $property, $propertySchema->withJson($json)); + [$propertySchema] = $this->inheritPropertyType($schemaProcessor, $property, $propertySchema->withJson($json)); $json = $propertySchema->getJson(); // Check for filter keywords in if/then/else sub-schemas after type inheritance. diff --git a/src/Model/Validator/Factory/Composition/NotValidatorFactory.php b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php index 2a84cd3e..6ab84463 100644 --- a/src/Model/Validator/Factory/Composition/NotValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php @@ -50,7 +50,11 @@ public function modify( // 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($schemaProcessor, $property, $propertySchema); + [$propertySchema, $injectedTypeBranchIndices] = $this->inheritPropertyType( + $schemaProcessor, + $property, + $propertySchema, + ); // Check for filter keywords after type inheritance so that branches that inherit // 'object' from the parent are correctly treated as object-typed (their properties // are processed as a nested schema and are not subject to ComposedItem $value reset). @@ -76,6 +80,7 @@ public function modify( $property, $wrappedSchema, false, + $injectedTypeBranchIndices, ); $availableAmount = count($compositionProperties); diff --git a/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php index fc3f8c3f..cce11105 100644 --- a/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php @@ -39,7 +39,11 @@ public function modify( } $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); - $propertySchema = $this->inheritPropertyType($schemaProcessor, $property, $propertySchema); + [$propertySchema, $injectedTypeBranchIndices] = $this->inheritPropertyType( + $schemaProcessor, + $property, + $propertySchema, + ); $this->checkForFilterInBranches($property, $propertySchema); $onlyForDefinedValues = !($property instanceof BaseProperty) @@ -58,6 +62,7 @@ public function modify( $property, $wrappedSchema, false, + $injectedTypeBranchIndices, ); $resolvedCompositions = 0; diff --git a/src/Model/Validator/MultiTypeCheckValidator.php b/src/Model/Validator/MultiTypeCheckValidator.php index 669d8046..eb7305f7 100644 --- a/src/Model/Validator/MultiTypeCheckValidator.php +++ b/src/Model/Validator/MultiTypeCheckValidator.php @@ -7,20 +7,24 @@ use PHPModelGenerator\Exception\Generic\InvalidTypeException; use PHPModelGenerator\Model\Property\PropertyInterface; -/** - * Class MultiTypeCheckValidator - * - * @package PHPModelGenerator\Model\Validator - */ class MultiTypeCheckValidator extends PropertyValidator implements TypeCheckInterface { /** - * MultiTypeCheckValidator constructor. - * * @param string[] $types + * @param bool $treatObjectAsUninstantiatedShape True when "object" candidacy + * must be checked against a raw, + * not-yet-instantiated value - the + * property's own composition + * validator owns instantiation + * instead of ObjectInstantiationDecorator. + * See PropertyFactory::createMultiTypeProperty(). */ - public function __construct(protected array $types, PropertyInterface $property, bool $allowImplicitNull) - { + public function __construct( + protected array $types, + PropertyInterface $property, + bool $allowImplicitNull, + bool $treatObjectAsUninstantiatedShape = false, + ) { // if null is explicitly allowed we don't need an implicit null pass through if (in_array('null', $this->types)) { $allowImplicitNull = false; @@ -32,7 +36,11 @@ public function __construct(protected array $types, PropertyInterface $property, ' && ', array_map( static fn(string $allowedType): string => - ReflectionTypeCheckValidator::fromType($allowedType, $property)->getCheck(), + ReflectionTypeCheckValidator::fromType( + $allowedType, + $property, + $allowedType === 'object' && $treatObjectAsUninstantiatedShape, + )->getCheck(), $this->types, ) ) . ($allowImplicitNull ? ' && $value !== null' : ''), diff --git a/src/Model/Validator/ReflectionTypeCheckValidator.php b/src/Model/Validator/ReflectionTypeCheckValidator.php index 315d62cb..2ad03ee6 100644 --- a/src/Model/Validator/ReflectionTypeCheckValidator.php +++ b/src/Model/Validator/ReflectionTypeCheckValidator.php @@ -7,26 +7,22 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Utils\TypeCheck; -/** - * Class ReflectionTypeCheckValidator - * - * @package PHPModelGenerator\Model\Validator - */ class ReflectionTypeCheckValidator extends PropertyValidator { public static function fromType( string $type, PropertyInterface $property, + bool $treatObjectAsUninstantiatedShape = false, ): self { - return new self($type, $property); + return new self($type, $property, $treatObjectAsUninstantiatedShape); } - /** - * ReflectionTypeCheckValidator constructor. - */ - public function __construct(string $name, PropertyInterface $property) - { - $typeCheck = TypeCheck::buildNegatedJsonSchemaTypeCheck($name); + public function __construct( + string $name, + PropertyInterface $property, + bool $treatObjectAsUninstantiatedShape = false, + ) { + $typeCheck = TypeCheck::buildNegatedJsonSchemaTypeCheck($name, $treatObjectAsUninstantiatedShape); parent::__construct($property, $typeCheck, ''); } diff --git a/src/PropertyProcessor/PropertyFactory.php b/src/PropertyProcessor/PropertyFactory.php index a3230e5f..df012e72 100644 --- a/src/PropertyProcessor/PropertyFactory.php +++ b/src/PropertyProcessor/PropertyFactory.php @@ -29,6 +29,8 @@ use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator; use PHPModelGenerator\Model\Validator\TypeCheckInterface; +use PHPModelGenerator\PropertyProcessor\Decorator\Property\ObjectInstantiationDecorator; +use PHPModelGenerator\PropertyProcessor\Decorator\Property\PropertyDecoratorInterface; use PHPModelGenerator\PropertyProcessor\Decorator\Property\PropertyTransferDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; @@ -1151,6 +1153,18 @@ private function createMultiTypeProperty( $subJson = $json; unset($subJson['default']); + // When the multi-type property itself carries schema-root composition, the object + // variant's sub-schema re-processes that same composition a second time as its own + // nested class's schema-root composition - duplicating the composition already attached + // once, correctly, to $property itself (finalizeMultiTypeProperty()). That one correct + // copy needs the raw, not-yet-instantiated value to do its own branch matching, so below + // we drop the object variant's own ObjectInstantiationDecorator and tell + // MultiTypeCheckValidator to recognize an un-instantiated JSON-object-shaped array too. + $hasSchemaLevelComposition = isset($json['oneOf']) + || isset($json['anyOf']) + || isset($json['allOf']) + || isset($json['if']); + foreach ($types as $type) { $this->checkType($type, $schema); @@ -1184,6 +1198,8 @@ private function createMultiTypeProperty( $schema, $propertySchema, $totalSubCount, + $type, + $hasSchemaLevelComposition, &$collectedTypes, &$typeHints, &$resolvedSubCount, @@ -1203,6 +1219,16 @@ private function createMultiTypeProperty( ); } + if ($type === 'object' && $hasSchemaLevelComposition) { + // Without this, $property would pre-instantiate the raw value via this + // variant's decorator before its own composition validator's instanceof + // checks ever run - see the $hasSchemaLevelComposition comment above. + $subProperty->filterDecorators( + static fn(PropertyDecoratorInterface $decorator): bool => + !($decorator instanceof ObjectInstantiationDecorator), + ); + } + if ($subProperty->getDecorators()) { $property->addDecorator(new PropertyTransferDecorator($subProperty)); } @@ -1220,6 +1246,7 @@ private function createMultiTypeProperty( $schemaProcessor, $schema, $propertySchema, + $hasSchemaLevelComposition, ); }); } @@ -1263,6 +1290,9 @@ private function createSubTypeProperty( * * @param string[] $collectedTypes * @param string[] $typeHints + * @param bool $hasSchemaLevelComposition True when this property's own JSON carries + * oneOf/anyOf/allOf/if - see the matching flag + * in createMultiTypeProperty(). * * @throws SchemaException */ @@ -1273,6 +1303,7 @@ private function finalizeMultiTypeProperty( SchemaProcessor $schemaProcessor, Schema $schema, JsonSchema $propertySchema, + bool $hasSchemaLevelComposition = false, ): void { $hasNull = in_array('null', $collectedTypes, true); $nonNullTypes = array_values(array_filter( @@ -1284,8 +1315,12 @@ private function finalizeMultiTypeProperty( && !$property->isRequired(); $property->addValidator( - (new MultiTypeCheckValidator($collectedTypes, $property, $allowImplicitNull)) - ->withJsonPointer($propertySchema->getPointer() . '/type'), + (new MultiTypeCheckValidator( + $collectedTypes, + $property, + $allowImplicitNull, + $hasSchemaLevelComposition, + ))->withJsonPointer($propertySchema->getPointer() . '/type'), 2, ); diff --git a/src/SchemaProcessor/SchemaProcessor.php b/src/SchemaProcessor/SchemaProcessor.php index 74d93962..10dd72dc 100644 --- a/src/SchemaProcessor/SchemaProcessor.php +++ b/src/SchemaProcessor/SchemaProcessor.php @@ -706,7 +706,21 @@ public function transferComposedPropertiesToSchema(PropertyInterface $property, &$seenBranchPropertyNames, ): void { if (!$composedProperty->getNestedSchema()) { - if ($composedProperty->getType() !== null) { + // allOf requires every branch to hold simultaneously, so a typed branch + // with no nested schema (only an object-asserting branch gets one) can + // never be satisfied alongside this object context - a genuine + // contradiction (e.g. allOf: [{type: integer}] under an object schema). + // oneOf/anyOf/if-then-else need only ONE branch to match, so a + // differently-typed branch (e.g. the array branch of a oneOf reached while + // narrowing a `{type: [object, array]}` property to its object variant) + // is simply not reachable from here, not a contradiction. + $isConjunctiveComposition = is_a( + $validator->getCompositionProcessor(), + AllOfValidatorFactory::class, + true, + ); + + if ($isConjunctiveComposition && $composedProperty->getType() !== null) { throw new SchemaException( sprintf( "No nested schema for composed property %s in file %s found", @@ -718,10 +732,9 @@ public function transferComposedPropertiesToSchema(PropertyInterface $property, } // A branch with neither a nested schema nor an explicit type (e.g. a $ref - // to a definition carrying only annotation keywords such as example) - // matches any value and contributes no named properties to transfer - - // this is not a schema error, unlike a branch with an explicit type that - // still lacks a nested schema (a genuine type conflict, handled above). + // to a definition carrying only annotation keywords) matches any value and + // contributes no properties to transfer - not an error, unlike the + // conjunctive/typed conflict handled above. $this->finalizeComposedBranchResolution( in_array($composedProperty, $branchesForValidator, true), $totalBranches, diff --git a/src/Utils/TypeCheck.php b/src/Utils/TypeCheck.php index 14fe927d..3a59604a 100644 --- a/src/Utils/TypeCheck.php +++ b/src/Utils/TypeCheck.php @@ -79,30 +79,35 @@ public static function buildNegatedCompound(array $typeNames): string /** * Build a negated runtime check for a single JSON Schema type name, as used by the "type" - * keyword (TypeCheckValidator, and MultiTypeCheckValidator via ReflectionTypeCheckValidator - - * both always call this with exactly one type name per instance). + * keyword. Differs from negating buildCheck() for "array"/"object" because + * json_decode($x, true) maps both an empty JSON object `{}` and an empty JSON array `[]` to + * the same empty PHP array - see ObjectInstantiationDecorator.phptpl for the same ambiguity + * handled on the instantiation side. "array" requires array_is_list($value) to reject a PHP + * map masquerading as an array. This must NOT bleed into buildCheck()/buildCompound()/ + * buildNegatedCompound(), which are general PHP-type checks for filter input/output and must + * keep accepting any PHP array. * - * Identical to negating buildCheck() except for "array": a JSON object and a JSON array both - * decode to a PHP array via json_decode($x, true), so "type": "array" must additionally - * require array_is_list($value) to reject a JSON object represented as a PHP map. No special - * case is needed for an empty array: array_is_list() already treats [] as a list, so an empty - * JSON array correctly satisfies "type": "array" here. (An empty JSON *object* satisfying - * "type": "array" too is the inherent, accepted {} vs [] limitation - see - * ObjectInstantiationDecorator.phptpl, which carries the opposite-direction carve-out so an - * empty object is still accepted for "type": "object".) - * - * This must NOT bleed into buildCheck()/buildCompound()/buildNegatedCompound(), which - * represent general PHP-type checks for filter input/output types - a filter declaring - * "array" as an accepted PHP type must keep accepting any PHP array, list or map, since it - * operates on already-decoded PHP values rather than re-deriving JSON Schema type semantics. + * $treatObjectAsUninstantiatedShape applies the mirror-image "object" carve-out + * (`$value === []` also counts as an object), needed only by MultiTypeCheckValidator: when a + * multi-type property's own composition validator - not ObjectInstantiationDecorator - owns + * instantiation, "object" candidacy must be checked against the raw, not-yet-instantiated + * value. Without the carve-out, `{}` is wrongly rejected as "not an object" whenever the + * property's other candidate type isn't "array" too (pairing with "array" masks the gap, + * since `[]` then legitimately matches that candidate anyway). */ - public static function buildNegatedJsonSchemaTypeCheck(string $typeName): string - { - if ($typeName !== 'array') { - return self::buildNegatedCompound([$typeName]); + public static function buildNegatedJsonSchemaTypeCheck( + string $typeName, + bool $treatObjectAsUninstantiatedShape = false, + ): string { + if ($typeName === 'array') { + return '!(is_array($value) && array_is_list($value))'; + } + + if ($typeName === 'object' && $treatObjectAsUninstantiatedShape) { + return '!(is_object($value) || (is_array($value) && (!array_is_list($value) || $value === [])))'; } - return '!(is_array($value) && array_is_list($value))'; + return self::buildNegatedCompound([$typeName]); } /** diff --git a/tests/AbstractPHPModelGeneratorTestCase.php b/tests/AbstractPHPModelGeneratorTestCase.php index 16b1176a..1570a3c0 100644 --- a/tests/AbstractPHPModelGeneratorTestCase.php +++ b/tests/AbstractPHPModelGeneratorTestCase.php @@ -33,11 +33,6 @@ use ReflectionType; use ReflectionUnionType; -/** - * Class AbstractPHPModelGeneratorTest - * - * @package PHPModelGenerator\Tests\Objects - */ abstract class AbstractPHPModelGeneratorTestCase extends TestCase { protected const EXTERNAL_JSON_DIRECTORIES = []; @@ -50,6 +45,16 @@ abstract class AbstractPHPModelGeneratorTestCase extends TestCase protected string $lastGeneratedNamespacePrefix = ''; + /** + * Destination directories copyExternalJSON() has written outside TEST_BASE_DIR (see its + * docblock), tracked so registerExternalJsonCleanup()'s shutdown function can remove them. + * + * @var array + */ + private static array $externalJsonCopyRoots = []; + + private static bool $externalJsonCleanupRegistered = false; + /** * Set up an empty directory for the tests */ @@ -105,23 +110,98 @@ public function tearDown(): void } /** - * Copy given external JSON schema files into the tmp directory to make them available during model generation + * Copy given external JSON schema files into the tmp directory to make them available during + * model generation. + * + * The destination must sit one level above TEST_BASE_DIR, not inside it: an + * EXTERNAL_JSON_DIRECTORIES entry (e.g. "../SomeTest_external") is relative to the fixture's + * schema directory, which is also how the fixture's own `$ref` keywords reference it - + * resolved at generation time relative to TEST_BASE_DIR, where generateClass() writes the + * temporary schema file. + * + * That destination sits outside TEST_BASE_DIR, so bootstrap.php's shutdown cleanup never + * touches it - left alone it accumulates forever in the shared system temp directory. Each + * copy is therefore (a) preceded by clearing any pre-existing content at the destination, and + * (b) tracked in self::$externalJsonCopyRoots for registerExternalJsonCleanup() to remove at + * process exit. Known residual gap: the destination name is not session-unique (it can't be, + * without also rewriting every fixture's `$ref` strings), so two fully concurrent test-suite + * processes can still collide - accepted, since this only closes the never-cleaned + * accumulation a single/sequential run hits. */ private function copyExternalJSON(): void { - $baseDir = TEST_BASE_DIR . DIRECTORY_SEPARATOR; $copyBaseDir = __DIR__ . "/Schema/{$this->getStaticClassName()}/"; foreach (static::EXTERNAL_JSON_DIRECTORIES as $directory) { - $di = new RecursiveDirectoryIterator($copyBaseDir . $directory, FilesystemIterator::SKIP_DOTS); + $sourceRoot = realpath($copyBaseDir . $directory); + + if ($sourceRoot === false) { + continue; + } + + $destinationRoot = dirname(TEST_BASE_DIR) . DIRECTORY_SEPARATOR . basename($sourceRoot); + + self::$externalJsonCopyRoots[$destinationRoot] = true; + self::registerExternalJsonCleanup(); + self::removeDirectoryRecursively($destinationRoot); + + $sourceIterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($sourceRoot, FilesystemIterator::SKIP_DOTS), + ); - foreach (new RecursiveIteratorIterator($di, RecursiveIteratorIterator::CHILD_FIRST) as $file) { - @mkdir($baseDir . dirname(str_replace($copyBaseDir, '', (string) $file)), 0777, true); - @copy((string) $file, $baseDir . str_replace($copyBaseDir, '', (string) $file)); + foreach ($sourceIterator as $file) { + $destination = $destinationRoot . DIRECTORY_SEPARATOR + . substr((string) $file, strlen($sourceRoot) + 1); + + @mkdir(dirname($destination), 0777, true); + @copy((string) $file, $destination); } } } + /** + * Registers, at most once per process, a shutdown function that removes every destination + * copyExternalJSON() has recorded. Warnings inside must stay suppressed (@-operators in + * removeDirectoryRecursively()): this runs after the last test finishes, with no TestCase on + * the call stack, so an unsuppressed warning would crash the run via PHPUnit's + * NoTestCaseObjectOnCallStackException instead of just leaving the directory behind. + */ + private static function registerExternalJsonCleanup(): void + { + if (self::$externalJsonCleanupRegistered) { + return; + } + + self::$externalJsonCleanupRegistered = true; + + register_shutdown_function(static function (): void { + foreach (array_keys(self::$externalJsonCopyRoots) as $destinationRoot) { + self::removeDirectoryRecursively($destinationRoot); + } + }); + } + + /** + * A no-op when $directory does not exist. + */ + private static function removeDirectoryRecursively(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $file) { + $file->isDir() ? @rmdir($file->getRealPath()) : @unlink($file->getRealPath()); + } + + @rmdir($directory); + } + /** * Generate a class from a given JSON schema file and return the FQCN * diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index cbd20166..6c108366 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -626,36 +626,131 @@ public function testUnevaluatedPropertiesReferencedViaRef(): void } /** - * A composition branch expressed via `$ref` resolves to the referenced schema at generation - * time. Its `properties` declarations must contribute to the outer accumulator so an outer - * `unevaluatedProperties: false` sees the ref-resolved keys as evaluated. Two assertions - * on the same generated class: - * - `{name: "Alice", foo: "hi", bar: 5}` accepts — the ref-resolved allOf branch claims - * `foo` and `bar`; the local `properties` claims `name`; no unevaluated keys remain. - * - `{name: "Alice", stray: 1}` rejects — `stray` is not declared anywhere. + * A `$ref` composition branch (same-file `$defs`, or an external file - see + * EXTERNAL_JSON_DIRECTORIES above) resolves to the referenced schema at generation time, and + * its `properties` must contribute to the outer `unevaluatedProperties: false` accumulator. + * One data provider, not two methods: only `$ref` resolution differs between the rows (its + * own concern, covered in ReferencePropertyTest/RefSiblingsTest), not the behaviour under + * test here. + * + * Note: `ExternalRefBranch.json`'s target renders as an ordinary, fully-validating nested + * class, not an `ExternalSchema` placeholder (a `$ref` outside the provider's base directory + * doesn't by itself produce one - see testExternalSchemaPlaceholderBranchCreditsNothingLikeAVacuousBranch() + * for that case, which needs a target file with no `type`/`properties`/composition of its + * own). */ - public function testRefResolvedBranchContributesAnnotations(): void + #[DataProvider('refResolvedBranchDataProvider')] + public function testRefResolvedBranchContributesAnnotations( + string $schemaFile, + array $acceptedInput, + string $strayKey, + ): void { + $className = $this->generateClassFromFile($schemaFile); + + $accepted = new $className($acceptedInput); + $this->assertSame($acceptedInput, $accepted->meta()->rawInput()); + + try { + new $className(['name' => 'Alice', $strayKey => 1]); + $this->fail('An undeclared key must be rejected by unevaluatedProperties: false'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['{$strayKey}']", + $exception->getMessage(), + ); + $this->assertSame([$strayKey], $exception->getUnevaluatedProperties()); + } + } + + public static function refResolvedBranchDataProvider(): array { - $className = $this->generateClassFromFile('RefResolvedBranchContributesAnnotations.json'); + return [ + 'same-file $defs reference' => [ + 'RefResolvedBranchContributesAnnotations.json', + ['name' => 'Alice', 'foo' => 'hi', 'bar' => 5], + 'stray', + ], + 'external-file reference crossing a directory boundary' => [ + 'ExternalRefBranch.json', + ['name' => 'Alice', 'external' => 'hi'], + 'whatever', + ], + ]; + } - $accepted = new $className(['name' => 'Alice', 'foo' => 'hi', 'bar' => 5]); - $this->assertSame( - ['name' => 'Alice', 'foo' => 'hi', 'bar' => 5], - $accepted->meta()->rawInput(), + /** + * A `$ref` to an entire external file declaring no `type`/`properties`/composition (only + * `definitions`) renders as an `ExternalSchema` placeholder - a branch that's vacuously + * satisfied (like `{}`) but declares none of `properties`/`patternProperties`/ + * `additionalProperties`, so per the spec-mandated rule (combinedSchemas/allOf.rst's + * "Property and item evaluation propagation") it credits nothing to `unevaluatedProperties`, + * same as a literal `{}` or `true` branch. `whatever` is therefore correctly rejected. + * + * Also asserts `warnIfVacuousBranch()` still flags this branch even though it inherits the + * outer `type: object` - the vacuousness check must look past an inherited type to the + * branch's own authored content. + */ + public function testExternalSchemaPlaceholderBranchCreditsNothingLikeAVacuousBranch(): void + { + $logger = new RecordingLogger(); + + $className = $this->generateClassFromFile( + 'ExternalSchemaPlaceholderBranch.json', + (new GeneratorConfiguration())->setLogger($logger)->setCollectErrors(false), + ); + + $this->assertTrue( + $this->hasLogEntry( + $logger->getEntries(), + 'warning', + "Composition branch #{index} for '{property}' carries no validation keyword and" + . ' matches any value', + ['index' => 1], + ), + 'The placeholder branch declares no validation keyword and matches any value - it ' + . 'should be flagged as vacuous even though it inherits the outer type.', ); + $accepted = new $className(['name' => 'Alice']); + $this->assertSame(['name' => 'Alice'], $accepted->meta()->rawInput()); + try { - new $className(['name' => 'Alice', 'stray' => 1]); + new $className(['name' => 'Alice', 'whatever' => 'x']); $this->fail('An undeclared key must be rejected by unevaluatedProperties: false'); } catch (UnevaluatedPropertiesException $exception) { $this->assertSame( - "Provided JSON for '{$className}' contains not allowed unevaluated properties ['stray']", + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['whatever']", $exception->getMessage(), ); - $this->assertSame(['stray'], $exception->getUnevaluatedProperties()); + $this->assertSame(['whatever'], $exception->getUnevaluatedProperties()); } } + /** + * Non-regression companion: a branch that explicitly declares its own `type` (even one + * identical to the parent's) must stay silent - confirms the fix above distinguishes an + * inherited type from an author-declared one in both directions. + */ + public function testAuthorDeclaredTypeBranchStaysSilent(): void + { + $logger = new RecordingLogger(); + + $this->generateClassFromFile( + 'AuthorDeclaredTypeBranchStaysSilent.json', + (new GeneratorConfiguration())->setLogger($logger), + ); + + $this->assertFalse( + $this->hasLogEntry( + $logger->getEntries(), + 'warning', + "Composition branch #{index} for '{property}' carries no validation keyword and" + . ' matches any value', + ), + 'A branch with its own explicit type declaration must not be flagged as vacuous.', + ); + } + /** * A schema that references itself through `$defs` must not cause the activation walk to * recurse indefinitely — the walk's `seenSchemas` map is required (not defensive) for @@ -828,35 +923,6 @@ public function testAdditionalPropertiesValueSubschemaEnforcesOwnUnevaluatedProp new $className(['id' => 1, 'dyn' => ['known' => 'hi', 'stray' => 1]]); } - /** - * A `$ref` that resolves to a schema in a file outside the provider's base directory is - * represented at generation time as an `ExternalSchema` placeholder — a class the - * generator does not emit validation for. When such a placeholder is used as a composition - * branch, the design records that the branch's evaluated set is treated as covering every - * instance key, because we trust the external schema to enforce its own contract. - * - * End-to-end codegen is blocked today by the composition processor's insistence that every - * composed branch surface a nested schema. An `ExternalSchema` placeholder branch has - * `getNestedSchema() === null`, so SchemaProcessor throws before the unevaluated wiring - * ever runs. The same pre-existing limitation blocks a related test on the array-side - * self-referencing fixture. Fixing it lives in the composition processor's - * `shouldSkip()` / nested-schema expectation, which is out of scope for this topic. - * - * The fixture is kept in the test directory to document the intended behaviour and to - * accelerate the promotion of this case to a real assertion once the composition - * processor's nested-schema expectation is relaxed. - */ - public function testExternalRefBranchTreatsAllKeysAsEvaluated(): void - { - $this->markTestIncomplete( - 'End-to-end codegen blocked by the composition processor\'s requirement that ' - . 'every composed branch surface a nested schema. An ExternalSchema placeholder ' - . 'branch has no nested schema, so generation fails before the unevaluated ' - . 'accumulator sees the branch. The fix belongs to the composition processor ' - . '(same pre-existing bug that blocks the array-side self-referencing test).', - ); - } - /** * Sibling `additionalProperties` (or the effective `false` produced by the * `denyAdditionalProperties()` config flag) short-circuits the unevaluated bucket. Each row diff --git a/tests/Issues/Issue/Issue72Test.php b/tests/Issues/Issue/Issue72Test.php index a799cf68..b6b5ef2b 100644 --- a/tests/Issues/Issue/Issue72Test.php +++ b/tests/Issues/Issue/Issue72Test.php @@ -1011,6 +1011,8 @@ public function testArrayItemWithBareObjectValidatorsValidatesObjectItemsAndPass * while branches containing only "const" or "type" - both registered on their Type via * addModifier() rather than addValidator() in Draft_07, so invisible to * Draft::getTypesForKeyword() - must not, since both are genuine constraints. + * + * Also covers 'not', including the type-inheritance exclusion (notBranchInheritingParentType). */ public function testVacuousBranchWarningIsDrivenByRegisteredValidatorsNotAHardcodedList(): void { @@ -1095,6 +1097,21 @@ public function testVacuousBranchWarningIsDrivenByRegisteredValidatorsNotAHardco $this->assertNotNull($emptyBranchEntry, 'Expected a warning entry for the emptyBranchParity property.'); $this->assertSame($trueBranchEntry['message'], $emptyBranchEntry['message']); $this->assertSame($trueBranchEntry['context']['index'], $emptyBranchEntry['context']['index']); + + // 'not' inherits the parent type the same way allOf/anyOf/oneOf do, and the inherited + // type must be excluded from vacuousness the same way too - notBranchInheritingParentType + // declares nothing of its own, so without the exclusion it would look identical to an + // author-declared, non-vacuous `not: {"type": "string"}`. + $this->assertTrue( + $this->hasLogEntry( + $entries, + 'warning', + "Composition branch #{index} for '{property}' carries no validation keyword and" + . ' matches any value', + ['index' => 1, 'property' => 'notBranchInheritingParentType'], + ), + 'Expected a vacuous-branch warning for the notBranchInheritingParentType property.', + ); } /** diff --git a/tests/Objects/MultiTypePropertyTest.php b/tests/Objects/MultiTypePropertyTest.php index d128c315..fd6ff5cf 100644 --- a/tests/Objects/MultiTypePropertyTest.php +++ b/tests/Objects/MultiTypePropertyTest.php @@ -17,11 +17,6 @@ use PHPModelGenerator\Tests\Support\ApplicableDrafts; use PHPUnit\Framework\Attributes\DataProvider; -/** - * Class MultiTypePropertyTest - * - * @package PHPModelGenerator\Tests\Objects - */ #[ApplicableDrafts] class MultiTypePropertyTest extends AbstractPHPModelGeneratorTestCase { @@ -325,4 +320,124 @@ public static function invalidRecursiveMultiTypeDataProvider(): array ], ]; } + + /** + * A property typed `["object", "array"]` with a `oneOf` spanning an object branch and an + * array branch used to throw a generation-time SchemaException: narrowing to the object + * variant re-processes the full `oneOf` as that nested class's own schema-root composition, + * and only an object-typed branch ever gets a nested schema, so the array branch looked like + * a conflict. SchemaProcessor::transferComposedPropertiesToSchema() now only treats a typed, + * schema-less branch as a conflict for allOf (which needs every branch to hold at once) - + * not oneOf/anyOf/if-then-else, where an unmatched branch simply isn't reached. + * + * Also covers the object branch on the same class: without further changes it would be + * double-validated against incompatible value shapes (the property-level `oneOf` copy would + * run its `instanceof` checks after the nested copy had already instantiated the value into + * an unrelated class). PropertyFactory::createMultiTypeProperty() now drops the object + * variant's own ObjectInstantiationDecorator and has MultiTypeCheckValidator recognize a raw, + * not-yet-instantiated JSON-object-shaped array, so only the property-level composition + * validator instantiates. + */ + public function testMultiTypePropertyWithCompositionArrayBranchGeneratesAndValidatesArrayInput(): void + { + $className = $this->generateClassFromFile('MultiTypePropertyWithOneOfArrayBranch.json'); + + $arrayObject = new $className(['property' => ['Test']]); + $this->assertSame(['Test'], $arrayObject->getProperty()); + + $objectObject = new $className(['property' => ['name' => 'Hans']]); + $this->assertSame('Hans', $objectObject->getProperty()->getName()); + } + + #[DataProvider('invalidCompositionArrayBranchDataProvider')] + public function testInvalidMultiTypePropertyCompositionArrayBranchThrowsAnException( + mixed $propertyValue, + string $exceptionMessage, + ): void { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage($exceptionMessage); + + $className = $this->generateClassFromFile('MultiTypePropertyWithOneOfArrayBranch.json'); + + new $className(['property' => $propertyValue]); + } + + public static function invalidCompositionArrayBranchDataProvider(): array + { + return [ + 'wrong item type in tuple' => [ + [42], + << [ + 'nope', + "Invalid type for 'property': requires ['object', 'array'], got 'string'", + ], + ]; + } + + public function testInvalidMultiTypePropertyCompositionObjectBranchThrowsAnException(): void + { + $this->expectException(ValidationException::class); + // The array branch's own tuple-item detail beyond the initial type mismatch varies by + // draft (some drafts short-circuit further checks once the primary array-type check + // already failed); only the draft-independent prefix is asserted here. + $this->expectExceptionMessageMatches('/' . preg_quote(<<generateClassFromFile('MultiTypePropertyWithOneOfArrayBranch.json'); + + new $className(['property' => ['name' => 42]]); + } + + /** + * An empty JSON object `{}` and an empty JSON array `[]` both decode to the same empty PHP + * array (see TypeCheck::buildNegatedJsonSchemaTypeCheck() for the `object`/`array` ambiguity + * this causes). `{}` used to be flatly rejected as "not an object" for a multi-type object + * candidate whenever the sibling type wasn't `array` - pairing with `array` happened to mask + * the gap, which is why this needs its own fixture pairing "object" with "string" instead of + * reusing MultiTypePropertyWithOneOfArrayBranch.json. + */ + public function testMultiTypePropertyObjectCandidateAcceptsEmptyObjectInput(): void + { + $className = $this->generateClassFromFile('MultiTypePropertyWithOneOfObjectStringBranch.json'); + + $emptyObject = new $className(['property' => []]); + $this->assertNull($emptyObject->getProperty()->getName()); + + $namedObject = new $className(['property' => ['name' => 'Alice']]); + $this->assertSame('Alice', $namedObject->getProperty()->getName()); + + $string = new $className(['property' => 'abc']); + $this->assertSame('abc', $string->getProperty()); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage( + << 'ab']); + } } diff --git a/tests/Schema/Issues/72/VacuousBranchWarning.json b/tests/Schema/Issues/72/VacuousBranchWarning.json index 5c82d46f..2a41217f 100644 --- a/tests/Schema/Issues/72/VacuousBranchWarning.json +++ b/tests/Schema/Issues/72/VacuousBranchWarning.json @@ -84,6 +84,10 @@ }, {} ] + }, + "notBranchInheritingParentType": { + "type": "string", + "not": {} } } } diff --git a/tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfArrayBranch.json b/tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfArrayBranch.json new file mode 100644 index 00000000..b14e8e3f --- /dev/null +++ b/tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfArrayBranch.json @@ -0,0 +1,29 @@ +{ + "type": "object", + "properties": { + "property": { + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + { + "type": "array", + "items": [ + { + "type": "string" + } + ] + } + ] + } + } +} diff --git a/tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfObjectStringBranch.json b/tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfObjectStringBranch.json new file mode 100644 index 00000000..0b840473 --- /dev/null +++ b/tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfObjectStringBranch.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "properties": { + "property": { + "type": [ + "object", + "string" + ], + "oneOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + { + "type": "string", + "minLength": 3 + } + ] + } + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AuthorDeclaredTypeBranchStaysSilent.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AuthorDeclaredTypeBranchStaysSilent.json new file mode 100644 index 00000000..11203a9a --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AuthorDeclaredTypeBranchStaysSilent.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "type": "object" + } + ] +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalSchemaPlaceholderBranch.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalSchemaPlaceholderBranch.json new file mode 100644 index 00000000..28b1e45e --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalSchemaPlaceholderBranch.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "$ref": "../UnevaluatedPropertiesValidatorTest_external/definitionsOnlyPlaceholderTarget.json" + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest_external/definitionsOnlyPlaceholderTarget.json b/tests/Schema/UnevaluatedPropertiesValidatorTest_external/definitionsOnlyPlaceholderTarget.json new file mode 100644 index 00000000..eef5baef --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest_external/definitionsOnlyPlaceholderTarget.json @@ -0,0 +1,7 @@ +{ + "definitions": { + "unused": { + "type": "string" + } + } +} From 5b454c24c8e5f2039f254d002969b2fa790e2a17 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Wed, 19 Aug 2026 23:55:22 +0200 Subject: [PATCH 36/51] Close post-implementation review item 1: activation-walk caching Profiled UnevaluatedPropertiesPostProcessor::needsActivation() against a synthetic worst-case schema (many top-level classes sharing one deep $ref chain, no unevaluatedProperties anywhere to short-circuit the walk). Cost tracks (containers x depth) as predicted but stays in the 2-7% band of total generation time even under this adversarial shape - not a hot path, and a persistent Schema-level cache would carry real invalidation risk against post processors that still mutate schemas after the walk runs. Closed without adding caching; see the plan for the full measurement. Co-Authored-By: Claude Sonnet 5 --- src/SchemaProcessor/RenderQueue.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/SchemaProcessor/RenderQueue.php b/src/SchemaProcessor/RenderQueue.php index 8a90c37d..4f0c65b0 100644 --- a/src/SchemaProcessor/RenderQueue.php +++ b/src/SchemaProcessor/RenderQueue.php @@ -10,11 +10,6 @@ use PHPModelGenerator\Model\RenderJob; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; -/** - * Class RenderQueue - * - * @package PHPModelGenerator\SchemaProcessor - */ class RenderQueue { /** @var RenderJob[] */ From 2f7e74f74497b26e208fe50654c2a15e0e57179c Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 20 Aug 2026 00:07:21 +0200 Subject: [PATCH 37/51] Close post-implementation review item 3: branch-affected-property set audit Walked every composition-branch occurrence of the nine undeclared-key-sensitive keywords across the test fixture set (43 real hits) and traced through how additionalProperties/patternProperties/propertyNames/unevaluatedProperties are scoped per-branch, not per-schema: a branch's own declared names are the only ones excluded from its own undeclared-key check, so any other schema property (declared by a sibling branch or the base schema) is genuinely relevant, not merely a conservative safety margin. No real fixture shows the "map every property" fallback over-mapping in practice. Closed without changes. Co-Authored-By: Claude Sonnet 5 --- src/Model/Property/CompositionPropertyDecorator.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Model/Property/CompositionPropertyDecorator.php b/src/Model/Property/CompositionPropertyDecorator.php index 083b648b..4fc3cf63 100644 --- a/src/Model/Property/CompositionPropertyDecorator.php +++ b/src/Model/Property/CompositionPropertyDecorator.php @@ -45,8 +45,6 @@ class CompositionPropertyDecorator extends PropertyProxy private bool $alwaysTrueBranch = false; /** - * CompositionPropertyDecorator constructor. - * * @throws SchemaException */ public function __construct(string $propertyName, JsonSchema $jsonSchema, PropertyInterface $property) From d2604b5b9d1936dd1b1763c981635ea5128ae062 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 25 Aug 2026 17:00:06 +0200 Subject: [PATCH 38/51] Close post-implementation review item 5: test-suite runtime impact Measured the two isolatable components of overhead since a clean before/after checkout isn't available on this branch (155 commits diverged from master, spanning unrelated multi-draft work): the new unevaluatedProperties tests are 3.5% of total suite wall-clock, and the always-on UnevaluatedPropertiesPostProcessor adds a 5.1% marginal generation-time tax to schemas that never opt into the feature (measured via reflection A/B over 137 real existing fixtures) - combined estimate lands in the 6-9% range, under the item's own >10% investigate-further threshold. Closed without adding activation-walk caching, consistent with item 1. Also adds a tracked, deliberately-failing regression test for a genuine but unrelated bug found while assembling the benchmark batch: a schema whose derived class name collides with a PHP-reserved word (e.g. "ReadOnly", "readonly" reserved since PHP 8.1) generates uncompilable PHP instead of failing cleanly at generation time. Full analysis and a related second collision case (with the generated class's own fixed imports) in .claude/topics/reserved-php-classname-collision/analysis.md - deferred as a separate topic, out of scope for the unevaluated-properties feature. Co-Authored-By: Claude Sonnet 5 --- tests/Basic/BasicSchemaGenerationTest.php | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/Basic/BasicSchemaGenerationTest.php b/tests/Basic/BasicSchemaGenerationTest.php index ff4a29ac..6c777882 100644 --- a/tests/Basic/BasicSchemaGenerationTest.php +++ b/tests/Basic/BasicSchemaGenerationTest.php @@ -135,6 +135,39 @@ public function testReadOnlyPropertyDoesntGenerateSetter(): void $this->assertTrue(is_callable([$object, 'setNoReadOnly'])); } + /** + * A schema whose derived class name collides with a PHP-reserved class name (`readonly` is + * reserved since PHP 8.1 - see https://www.php.net/manual/en/reserved.other-reserved-words.php) + * currently generates uncompilable PHP: the class is written to disk, then RenderJob::render() + * `require`s it, and PHP throws `ParseError: syntax error, unexpected token "readonly", + * expecting identifier` - not a `SchemaException` raised cleanly at generation time. + * + * Explicit inline `"title": "ReadOnly"` plus `$originalClassNames: true` is required to + * reproduce this through the test harness: `generateClassFromFile()`'s default path always + * writes the schema to a temp file named after the *test's own* generated class name (see + * AbstractPHPModelGeneratorTestCase::generateClass()) regardless of the flag, so the + * `ReadOnly.json` fixture used elsewhere in this file never actually reaches the generator + * under the name "ReadOnly" - only an explicit `title` (which the class-name generator + * prioritizes over the filename) reliably forces the collision. + * + * Unrelated to the `readOnly` JSON Schema keyword under test elsewhere in this file; this is + * purely about a schema's own name being reused, unqualified, as the derived PHP class name. + * Tracked in .claude/topics/reserved-php-classname-collision/ as a deferred, separate topic - + * out of scope for whatever change happens to be touching this file. Asserts the intended + * behavior (a SchemaException raised at generation time); currently fails because the real + * exception is a ParseError raised later, from require(). + */ + public function testClassNameCollidingWithPhpReservedWordThrowsSchemaException(): void + { + $this->expectException(SchemaException::class); + + $this->generateClass( + '{"title": "ReadOnly", "type": "object", "properties": {"name": {"type": "string"}}}', + null, + true, + ); + } + public function testSetterChangeTheInternalState(): void { $className = $this->generateClassFromFile( From fb6dba849f38342d9657936080d46152859966e2 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 25 Aug 2026 17:25:55 +0200 Subject: [PATCH 39/51] Fix two infinite recursions on self-referencing array compositions CompositionTypeHintDecorator::getTypeHint() recursed indefinitely on a schema like {type: array, allOf: [{$ref: "#"}], unevaluatedItems: false} - the composed branch's wrapped property carries the same decorator instance again. Fixed with a per-instance recursion-depth guard mirroring ArrayTypeHintDecorator's existing pattern for the analogous cycle; on re-entry it skips itself for the nested getTypeHint() call instead of reapplying, without degrading type hints for genuinely nested (non-cyclic) composition. Fixing that let generation get further and surface a second, previously undocumented recursion in the same subsystem: UnevaluatedPropertiesPostProcessor::propertyHasBranchUnevaluatedItems() recurses into a branch's wrapped property with no cycle guard whenever the branch has no nested Schema - true for every array-typed branch, so true for this same self-referencing shape. Fixed with a $seen map keyed on file+pointer (not object identity, since getOrderedValidators() returns fresh clones per call), mirroring needsActivation()'s existing schema-level guard. Both verified red-then-green by reverting each fix in isolation and confirming Xdebug's own infinite-loop guard catches the un-fixed method. A third, deeper bug surfaced once generation started succeeding: constructing an instance still stack-overflows, because allOf: [{$ref: "#"}]} applied directly to a property (not through items) is a degenerate constraint with no base case. Verified this doesn't affect the actually useful items: {$ref: "#"} recursive pattern, which works correctly. Deferred as its own topic (.claude/topics/self-composition-runtime-recursion/) since a real fix is a design decision (reject at generation time vs. runtime cycle memoization), not a mechanical one. Co-Authored-By: Claude Sonnet 5 --- .../TypeHint/CompositionTypeHintDecorator.php | 22 +++++--- .../UnevaluatedPropertiesPostProcessor.php | 24 +++++++-- tests/Basic/UnevaluatedItemsValidatorTest.php | 53 ++++++++++--------- 3 files changed, 65 insertions(+), 34 deletions(-) diff --git a/src/PropertyProcessor/Decorator/TypeHint/CompositionTypeHintDecorator.php b/src/PropertyProcessor/Decorator/TypeHint/CompositionTypeHintDecorator.php index 4ab566e6..0379d3c7 100644 --- a/src/PropertyProcessor/Decorator/TypeHint/CompositionTypeHintDecorator.php +++ b/src/PropertyProcessor/Decorator/TypeHint/CompositionTypeHintDecorator.php @@ -6,13 +6,10 @@ use PHPModelGenerator\Model\Property\PropertyInterface; -/** - * Class CompositionTypeHintDecorator - * - * @package PHPModelGenerator\PropertyProcessor\Decorator\Property - */ class CompositionTypeHintDecorator implements TypeHintDecoratorInterface { + private int $recursionDepth = 0; + public function __construct(protected PropertyInterface $nestedProperty) {} @@ -21,7 +18,20 @@ public function __construct(protected PropertyInterface $nestedProperty) */ public function decorate(string $input, bool $outputType = false): string { - return (new TypeHintDecorator(explode('|', $this->nestedProperty->getTypeHint($outputType)))) + // A self-referencing composition branch (e.g. {allOf: [{$ref: "#"}]}) wraps a property + // whose own type-hint decorators include this very instance again - without a re-entry + // guard, getTypeHint() would recurse indefinitely. On re-entry, skip this decorator + // class for the nested call instead of applying it again; ArrayTypeHintDecorator uses + // the same pattern for the analogous array-composition cycle. + if (++$this->recursionDepth > 1) { + return $this->nestedProperty->getTypeHint($outputType, [self::class]); + } + + $result = (new TypeHintDecorator(explode('|', $this->nestedProperty->getTypeHint($outputType)))) ->decorate($input, $outputType); + + $this->recursionDepth--; + + return $result; } } diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php index 4ccc54bf..7e891a0e 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -237,9 +237,25 @@ private function activateArrayPropertyTracking(Schema $schema): void * through the branch's wrapped property's own validators instead mirrors how * `activateValidatorsInBranch()` already walks this exact structure for the activation step * itself. + * + * A self-referencing array composition (e.g. `{type: array, allOf: [{$ref: "#"}]}`) makes + * the wrapped property this recurses into the same property again, indefinitely — required + * cycle protection, not defensive, the same class of cycle `needsActivation()` guards + * against for schemas. Keyed on file+pointer rather than object identity because + * `getOrderedValidators()` returns fresh clones on every call (see + * `activateValidatorsInBranch()`'s own comment on this), so the wrapped `PropertyInterface` + * instance is not guaranteed stable across recursive calls even for the same schema location. */ - private function propertyHasBranchUnevaluatedItems(PropertyInterface $property): bool + private function propertyHasBranchUnevaluatedItems(PropertyInterface $property, array &$seen = []): bool { + $propertyKey = $property->getJsonSchema()->getFile() . '#' . $property->getJsonSchema()->getPointer(); + + if (array_key_exists($propertyKey, $seen)) { + return $seen[$propertyKey]; + } + + $seen[$propertyKey] = false; + foreach ($property->getOrderedValidators() as $validator) { if (!$validator instanceof AbstractComposedPropertyValidator) { continue; @@ -247,14 +263,14 @@ private function propertyHasBranchUnevaluatedItems(PropertyInterface $property): foreach ($validator->getComposedProperties() as $composedProperty) { if (array_key_exists('unevaluatedItems', $composedProperty->getBranchSchema()->getJson())) { - return true; + return $seen[$propertyKey] = true; } if ( $composedProperty->getNestedSchema() === null - && $this->propertyHasBranchUnevaluatedItems($composedProperty->getWrappedProperty()) + && $this->propertyHasBranchUnevaluatedItems($composedProperty->getWrappedProperty(), $seen) ) { - return true; + return $seen[$propertyKey] = true; } } } diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 34221b5b..62a98266 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -657,35 +657,40 @@ public function testItemsPlusContainsBranchUnionsBothClaimSources(): void } /** - * Regression guard for the recursive activation walk in - * `UnevaluatedPropertiesPostProcessor::activateArrayComposition()`: a schema like - * `{type: array, allOf: [{$ref: "#/definitions/recursive"}], unevaluatedItems: false}` - * where the $ref resolves back to the same schema produces a composition validator - * whose composed branch's wrapped property carries the same composition validator - * instance. Without the cycle break, the walk recurses indefinitely; with it, the walk - * short-circuits on the second visit. + * Regression guard for three independent, previously-infinite recursions on the same + * self-referencing shape - `{type: array, allOf: [{$ref: "#/definitions/recursive"}], + * unevaluatedItems: false}`, where the $ref resolves back to the same schema: * - * The cycle-break guard was verified by hand: removing it from - * `activateArrayComposition()` and running this fixture causes the process to abort - * with `Xdebug has detected a possible infinite loop, and aborted your script with a - * stack depth of '512' frames`, with the offending frames pointing at the - * `activateArrayComposition` / `activateValidatorsInBranch` pair. With the guard - * restored, the walk terminates. + * - The activation walk in `UnevaluatedPropertiesPostProcessor::activateArrayComposition()` + * - guarded by `$activatedCompositions`. Verified by hand: removing the guard and + * running this fixture aborts with "Xdebug has detected a possible infinite loop... a + * stack depth of '512' frames", frames pointing at `activateArrayComposition` / + * `activateValidatorsInBranch`. + * - `CompositionTypeHintDecorator::getTypeHint()`, which recurses into the composed + * branch's wrapped property - itself, on this fixture - with no cycle protection. + * Fixed by a per-instance recursion-depth guard mirroring `ArrayTypeHintDecorator`'s + * existing pattern for the analogous array-composition cycle. + * - `UnevaluatedPropertiesPostProcessor::propertyHasBranchUnevaluatedItems()`, which + * recurses into `getWrappedProperty()` whenever a branch has no nested `Schema` (true + * for every array-typed branch) - also unguarded, only surfaced once the other two were + * fixed. Fixed with a `$seen` map keyed on file+pointer, mirroring `needsActivation()`'s + * own guard; keyed on the string location rather than object identity because + * `getOrderedValidators()` returns fresh clones on every call, so the wrapped property + * instance isn't guaranteed stable across recursive calls. * - * The test is marked incomplete because end-to-end codegen still hits a *second* - * infinite recursion in `CompositionTypeHintDecorator::getTypeHint()` on the same - * fixture — a pre-existing bug in the type-hint computation path that has no cycle - * protection of its own. Fixing that is outside the unevaluatedItems work; the test - * will be promoted to a real assertion once the type-hint recursion is closed. + * Generation now completes. Constructing an instance of the generated class still crashes + * with a stack overflow - the schema means "this array must recurse into a composition + * requiring itself, unconditionally", a degenerate constraint with no base case (unlike the + * `items: {$ref: "#"}` shape, which recurses into progressively smaller array *elements* + * and terminates correctly - verified separately, not affected by this). Tracked as its own, + * deeper, runtime issue: see the implementation plan for the fix options considered + * (rejecting the degenerate shape at generation time vs. runtime cycle memoization). */ public function testSelfReferencingArrayCompositionDoesNotRecurseIndefinitely(): void { - $this->markTestIncomplete( - 'Activation-walk cycle break is in place and verified by hand. End-to-end ' - . 'codegen blocked by an unrelated infinite recursion in ' - . 'CompositionTypeHintDecorator::getTypeHint() on self-referencing schemas ' - . '(deferred bug; tracked in the implementation plan).', - ); + $className = $this->generateClassFromFile('SelfReferencingArrayComposition.json'); + + $this->assertNotEmpty($className); } /** From 8befdeac985fed445780d184ff09d6eeb93b2dd3 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 25 Aug 2026 17:37:32 +0200 Subject: [PATCH 40/51] Close post-implementation review item 19: getNestedSchema() null-safety audit Read all 7 flagged call sites in PropertyFactory.php, RefResolver.php, PropertyAttributeSynthesizer.php, and SchemaProcessor.php in full context - every one is already correctly guarded (an early-return guard clause, a directly-preceding null check, a ternary, or safe by construction). No fix needed. The stress fixture built to raise confidence beyond static reading (an anyOf-based mutual-reference pair, exercising the two least-obviously-covered SchemaProcessor.php sites) surfaced a much larger, separate bug instead: constructing an instance of a mutually-referencing anyOf/oneOf composition of objects stack-overflows, since each branch must stay independently instantiable (unlike allOf, which flattens mutually-referencing branches at generation time - verified by inspecting the existing passing allOf fixture's generated output). Folded into the runtime-recursion topic item 15 already opened, since it changes that topic's fix-option recommendation. Surfacing test added (generation succeeds; construction is known to hang, so the test stops short of it and is marked incomplete). Co-Authored-By: Claude Sonnet 5 --- tests/Objects/ReferencePropertyTest.php | 37 +++++++++++++++++++ .../A.json | 17 +++++++++ .../B.json | 17 +++++++++ 3 files changed, 71 insertions(+) create mode 100644 tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/A.json create mode 100644 tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/B.json diff --git a/tests/Objects/ReferencePropertyTest.php b/tests/Objects/ReferencePropertyTest.php index 38b025cc..8ce09d08 100644 --- a/tests/Objects/ReferencePropertyTest.php +++ b/tests/Objects/ReferencePropertyTest.php @@ -1038,6 +1038,43 @@ public function testMutuallyReferencingRootCompositionsGenerateWithoutRecursingI $this->assertInstanceOf($bClass, new $bClass([])); } + /** + * The `anyOf`/`oneOf` analogue of the `allOf` case above does NOT terminate, unlike that + * one. `allOf` branches referencing each other get flattened into one class per file at + * generation time (`RefResolver`'s `hasComposedPropertyValidator()` + + * `SchemaProcessor::transferComposedPropertiesToSchema()` merge a referenced allOf's + * properties/validators directly into the referencing schema, since allOf requires every + * branch simultaneously) - confirmed by inspecting the allOf fixture's own generated output: + * `A`'s constructor never references `B` at all, only two classes exist in total. + * + * `anyOf`/`oneOf` cannot use the same trick: only one branch must hold, so each branch stays + * an independently instantiable class, and testing a mutually-referencing branch means + * actually constructing the referenced class. Constructing `A` here tries the second `anyOf` + * branch (a full `B`), which tries *its* second branch (a full `A`), forever - no base case. + * Confirmed via direct reproduction: generation succeeds (two classes plus their branch + * companions), but `new $aClass([])` stack-overflows (Xdebug's own infinite-loop guard + * catches it at ~512 frames). + * + * Deferred - a real fix is a design decision (reject the mutual reference at generation + * time, which would refuse a legitimately useful "recursive polymorphic node" schema shape + * unlike the tautological allOf case, vs. runtime cycle memoization), not a mechanical one. + * See `.claude/topics/self-composition-runtime-recursion/analysis.md` for the full + * analysis. Marked incomplete rather than attempting construction, to avoid burning ~512 + * stack frames through Xdebug on every suite run for a known, already-diagnosed failure. + */ + public function testMutuallyReferencingAnyOfRootCompositionsDoesNotYetTerminate(): void + { + $namespace = 'MutuallyReferencingAnyOfRootCompositions'; + $this->generateDirectory('MutuallyReferencingAnyOfRootCompositions', $this->directoryConfig($namespace)); + + $this->markTestIncomplete( + 'Generation succeeds, but constructing an instance recurses indefinitely - a ' + . 'mutually-referencing anyOf/oneOf composition has no base case for runtime ' + . 'branch-matching, unlike allOf (which flattens at generation time instead). ' + . 'Deferred; tracked in .claude/topics/self-composition-runtime-recursion/.', + ); + } + /** * A recursive cross-file schema pair - A's root is an `allOf` of a `$ref` to B, and B has a * property referencing A back - must produce exactly one class per file. diff --git a/tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/A.json b/tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/A.json new file mode 100644 index 00000000..0aa7923e --- /dev/null +++ b/tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/A.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "anyOf": [ + { + "type": "object", + "properties": { + "exclusiveProp": { + "type": "string", + "default": "fromA" + } + } + }, + { + "$ref": "B.json" + } + ] +} diff --git a/tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/B.json b/tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/B.json new file mode 100644 index 00000000..7e6872a6 --- /dev/null +++ b/tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/B.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "anyOf": [ + { + "type": "object", + "properties": { + "exclusiveProp": { + "type": "string", + "default": "fromB" + } + } + }, + { + "$ref": "A.json" + } + ] +} From e6a12b5fb020158071faa4b9b945e460aca81021 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 25 Aug 2026 17:46:42 +0200 Subject: [PATCH 41/51] Deepen analysis on post-implementation review item 20 (still deferred) Investigated whether the down-propagation gap (a composition branch's own unevaluatedProperties/unevaluatedItems can't see names/indices the enclosing schema or a sibling branch already claims) is tractable to fix now. It is not, as a single change: the enclosing schema's own declared names are static, generation-time-known information that could in principle be baked into a branch's generated validator call, but a sibling branch's claims can be instance-dependent and branches validate one at a time in a single forward pass (confirmed in ComposedItem.phptpl), so a spec-correct fix needs a two-pass evaluation model, not a call-site patch. Shipping only the tractable half was considered and rejected - it would leave the sibling-branch direction silently broken behind a passing test that never exercises it. Confirmed the array side has the identical defect (the item's own "revisit trigger" had left this unverified): a sibling tuple items claim on an array property is invisible to an allOf branch's own unevaluatedItems: false. Added a markTestIncomplete regression test mirroring the existing object-side one, so the gap has a tracked, failing surfacing mechanism on both sides. Co-Authored-By: Claude Sonnet 5 --- tests/Basic/UnevaluatedItemsValidatorTest.php | 43 +++++++++++++++++++ ...valuatedItemsIgnoresSiblingTupleClaim.json | 18 ++++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/BranchUnevaluatedItemsIgnoresSiblingTupleClaim.json diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 62a98266..78c24453 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -1143,4 +1143,47 @@ public function testSerializationCombinesTupleAndUnevaluatedItemsFiltersWithoutC $decoded = json_decode($object->toJSON(), true); $this->assertSame(['tags' => ['20201010', '2020-12-12']], $decoded); } + + /** + * Array-side counterpart of `UnevaluatedPropertiesValidatorTest:: + * testBranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations()` - same root cause, confirmed + * to affect this side too (the object-side write-up's own "revisit trigger" left this + * unverified). A branch's own `unevaluatedItems` has no visibility into indices a *sibling* + * applicator on the same array property already claims: here, the sibling tuple `items: + * [{type: string}]` claims index 0, but the `allOf` branch's own `unevaluatedItems: false` + * has no way to see that claim - it only knows what its own (empty) local applicators + * evaluated - so `['a']` at index 0 is wrongly rejected as unevaluated even though the + * sibling tuple already validated and claimed it. + * + * Root cause is architectural, not a call-site bug: a composition branch renders as its own, + * separately-instantiated generated class (constructed fresh from the raw array value), with + * no reference back to the enclosing property's own runtime state + * (`_evaluatedItemIndices`/`_compositionAnnotated`). Fixing this for the *sibling-claims* + * direction is harder than the enclosing-schema-declared-names case on the object side: the + * enclosing schema's own declared names are static, generation-time-known information that + * could in principle be baked into the branch's own generated validator call, but a sibling + * branch's own evaluated set can be genuinely instance-dependent (e.g. if the sibling is + * itself a composition or carries `patternProperties`), so a real fix needs the enclosing + * property to compute and pass its siblings' claims into the branch instance at + * construction time - not just a static list. See the implementation plan for the full + * writeup and the object-side companion bug this mirrors. + */ + public function testBranchUnevaluatedItemsIgnoresSiblingTupleClaim(): void + { + $this->markTestIncomplete( + 'Bug: a composition branch\'s own unevaluatedItems does not see indices a sibling ' + . 'applicator (here, a sibling tuple items) already claims on the same array ' + . 'property - the branch\'s nested class has no visibility into claims made outside ' + . 'itself. Array-side counterpart of the object-side bug tracked in ' + . 'implementation-plan.md; see the class docblock above for the full analysis.', + ); + + // @phpstan-ignore-next-line dead code — unreachable until the fix lands + $className = $this->generateClassFromFile('BranchUnevaluatedItemsIgnoresSiblingTupleClaim.json'); + + // Index 0 is declared and validated by the sibling tuple `items`, not by the branch - + // the branch's `unevaluatedItems: false` must still treat it as evaluated. + $accepted = new $className(['tags' => ['a']]); + $this->assertSame(['tags' => ['a']], $accepted->meta()->rawInput()); + } } diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/BranchUnevaluatedItemsIgnoresSiblingTupleClaim.json b/tests/Schema/UnevaluatedItemsValidatorTest/BranchUnevaluatedItemsIgnoresSiblingTupleClaim.json new file mode 100644 index 00000000..95eecab7 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/BranchUnevaluatedItemsIgnoresSiblingTupleClaim.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": [ + { + "type": "string" + } + ], + "allOf": [ + { + "unevaluatedItems": false + } + ] + } + } +} From 5ae9161d3d57bdff73ffd1b0c9369129453fef42 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 27 Aug 2026 15:52:51 +0200 Subject: [PATCH 42/51] Reject unsupported branch-level unevaluatedProperties/Items down-propagation A composition branch's own unevaluatedProperties/unevaluatedItems has no visibility into property names or indices declared by the enclosing schema or a sibling branch - only the reverse direction (branch to enclosing) is implemented. A real fix would need a two-pass evaluation model, which is provably ambiguous for anyOf/oneOf (two sibling branches can each gate on the other's claims with no unique consistent outcome). Rather than ship a partial fix or silently produce incorrect validation, generation now throws a new UnsupportedSchemaFeatureException (a SchemaException subclass) when this unsupported shape is detected, across allOf/anyOf/oneOf/if/then/else (not is exempt by design). Co-Authored-By: Claude Sonnet 5 --- docs/source/combinedSchemas/allOf.rst | 21 ++ docs/source/combinedSchemas/anyOf.rst | 10 + docs/source/combinedSchemas/if.rst | 11 + docs/source/combinedSchemas/oneOf.rst | 10 + .../UnsupportedSchemaFeatureException.php | 15 ++ .../UnevaluatedPropertiesPostProcessor.php | 191 ++++++++++++++++++ tests/Basic/UnevaluatedItemsValidatorTest.php | 57 +++--- .../UnevaluatedPropertiesValidatorTest.php | 141 +++++++++---- ...AnyOfBranchUnsupportedDownPropagation.json | 14 ++ ...tedPropertiesIgnoresOuterDeclarations.json | 1 + ...fThenBranchUnsupportedDownPropagation.json | 22 ++ ...hExemptFromUnsupportedDownPropagation.json | 12 ++ ...OneOfBranchUnsupportedDownPropagation.json | 14 ++ ...blingBranchUnsupportedDownPropagation.json | 16 ++ 14 files changed, 457 insertions(+), 78 deletions(-) create mode 100644 src/Exception/UnsupportedSchemaFeatureException.php create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfBranchUnsupportedDownPropagation.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenBranchUnsupportedDownPropagation.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchExemptFromUnsupportedDownPropagation.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/OneOfBranchUnsupportedDownPropagation.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/SiblingBranchUnsupportedDownPropagation.json diff --git a/docs/source/combinedSchemas/allOf.rst b/docs/source/combinedSchemas/allOf.rst index bcaf63b5..c5041380 100644 --- a/docs/source/combinedSchemas/allOf.rst +++ b/docs/source/combinedSchemas/allOf.rst @@ -158,3 +158,24 @@ For a value-typed ``array`` composition, the analogous indices contributed by ea identical extras behaviour but one writing the keyword and the other omitting it will therefore credit different evaluated sets. This is a spec-mandated distinction from JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. + +.. note:: + + Only this *up* direction (a branch's own declarations propagating to an enclosing + ``unevaluatedProperties``/``unevaluatedItems``) is implemented. The reverse — a branch's own + ``unevaluatedProperties``/``unevaluatedItems`` (other than a literal ``true``, which never + rejects anything) seeing property names or indices declared by the *enclosing* schema or a + *sibling* branch — is not currently supported. Generation throws + ``PHPModelGenerator\Exception\UnsupportedSchemaFeatureException`` for a branch shaped like + this: + + .. code-block:: json + + { + "type": "object", + "properties": { "name": { "type": "string" } }, + "allOf": [ { "unevaluatedProperties": false } ] + } + + since the branch has no way to know that ``name`` is already declared and validated by the + enclosing schema. diff --git a/docs/source/combinedSchemas/anyOf.rst b/docs/source/combinedSchemas/anyOf.rst index 5dcfda21..9fcc73ff 100644 --- a/docs/source/combinedSchemas/anyOf.rst +++ b/docs/source/combinedSchemas/anyOf.rst @@ -136,3 +136,13 @@ indices claimed by its ``items``/``additionalItems``/``contains``. identical extras behaviour but one writing the keyword and the other omitting it will therefore credit different evaluated sets. This is a spec-mandated distinction from JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. + +.. note:: + + Only this *up* direction (a branch's own declarations propagating to an enclosing + ``unevaluatedProperties``/``unevaluatedItems``) is implemented. The reverse — a branch's own + ``unevaluatedProperties``/``unevaluatedItems`` (other than a literal ``true``, which never + rejects anything) seeing property names or indices declared by the *enclosing* schema or a + *sibling* branch — is not currently supported and throws + ``PHPModelGenerator\Exception\UnsupportedSchemaFeatureException`` at generation time. See + `All Of `__'s equivalent note for a concrete example. diff --git a/docs/source/combinedSchemas/if.rst b/docs/source/combinedSchemas/if.rst index 45ae2704..cad8af18 100644 --- a/docs/source/combinedSchemas/if.rst +++ b/docs/source/combinedSchemas/if.rst @@ -252,3 +252,14 @@ and refreshed whenever the model is mutated. identical extras behaviour but one writing the keyword and the other omitting it will therefore credit different evaluated sets. This is a spec-mandated distinction from JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. + +.. note:: + + Only this *up* direction (a branch's own declarations propagating to an enclosing + ``unevaluatedProperties``/``unevaluatedItems``) is implemented. The reverse — a ``then`` or + ``else`` branch's own ``unevaluatedProperties``/``unevaluatedItems`` (other than a literal + ``true``, which never rejects anything) seeing property names or indices declared by the + *enclosing* schema, ``if``'s own declarations, or the other conditional branch — is not + currently supported and throws + ``PHPModelGenerator\Exception\UnsupportedSchemaFeatureException`` at generation time. See + `All Of `__'s equivalent note for a concrete example. diff --git a/docs/source/combinedSchemas/oneOf.rst b/docs/source/combinedSchemas/oneOf.rst index 53841c1e..580b129f 100644 --- a/docs/source/combinedSchemas/oneOf.rst +++ b/docs/source/combinedSchemas/oneOf.rst @@ -150,3 +150,13 @@ refreshed whenever the model is mutated. identical extras behaviour but one writing the keyword and the other omitting it will therefore credit different evaluated sets. This is a spec-mandated distinction from JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. + +.. note:: + + Only this *up* direction (a branch's own declarations propagating to an enclosing + ``unevaluatedProperties``/``unevaluatedItems``) is implemented. The reverse — a branch's own + ``unevaluatedProperties``/``unevaluatedItems`` (other than a literal ``true``, which never + rejects anything) seeing property names or indices declared by the *enclosing* schema or a + *sibling* branch — is not currently supported and throws + ``PHPModelGenerator\Exception\UnsupportedSchemaFeatureException`` at generation time. See + `All Of `__'s equivalent note for a concrete example. diff --git a/src/Exception/UnsupportedSchemaFeatureException.php b/src/Exception/UnsupportedSchemaFeatureException.php new file mode 100644 index 00000000..ec79a10e --- /dev/null +++ b/src/Exception/UnsupportedSchemaFeatureException.php @@ -0,0 +1,15 @@ +assertNoUnsupportedDownPropagation($schema); + // A schema with a non-false `unevaluatedProperties` validator tracks each key the // validator successfully evaluates in `_evaluatedPropertyKeys`. That field is read // by `_getEvaluatedProperties()` on nested branch classes so an enclosing @@ -94,6 +98,193 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu $this->activateArrayPropertyTracking($schema); } + /** + * Rejects a composition branch's `unevaluatedProperties`/`unevaluatedItems` when it cannot + * possibly be evaluated correctly: the branch renders as its own, separately-constructed + * class with no visibility into the enclosing schema's own declarations or a sibling + * branch's claims (only the reverse direction - branch claims propagating up - is + * implemented). Silently computing a wrong "unevaluated" set would either reject valid + * input or accept invalid input, so this fails loudly at generation time instead - see + * `.claude/topics/branch-unevaluated-down-propagation/` for the full design discussion, + * including why a general fix was rejected as either unsound (anyOf/oneOf: two sibling + * branches each gating on the other's claims can have no unique consistent answer at all, + * not just an expensive one to compute) or out of proportion to a pattern of unknown + * real-world frequency (allOf, where a fix is sound but non-trivial). + * + * `true` is exempt: it never rejects anything regardless of what the branch believes is + * evaluated, so the gap has no observable effect on validation outcomes. `not` is exempt + * because it already blocks annotations from crossing its boundary in both directions by + * design (see `not.rst`) - nothing about its own outcome depends on outside annotations. + */ + private function assertNoUnsupportedDownPropagation(Schema $schema): void + { + foreach ($schema->getBaseValidators() as $validator) { + if ($validator instanceof AbstractComposedPropertyValidator) { + $this->checkBranchesForUnsupportedDownPropagation( + $validator, + $schema->getJsonSchema()->getJson(), + $schema->getClassName(), + ); + } + } + + foreach ($schema->getProperties() as $schemaProperty) { + foreach ($schemaProperty->getOrderedValidators() as $validator) { + if ($validator instanceof AbstractComposedPropertyValidator) { + $this->checkBranchesForUnsupportedDownPropagation( + $validator, + $schemaProperty->getJsonSchema()->getJson(), + $schemaProperty->getName(), + ); + } + } + } + } + + /** + * @param array $enclosingJson The JSON of the schema/property the composition + * keyword itself sits on - not any one branch's. + */ + private function checkBranchesForUnsupportedDownPropagation( + AbstractComposedPropertyValidator $validator, + array $enclosingJson, + string $contextName, + ): void { + if (is_a($validator->getCompositionProcessor(), NotValidatorFactory::class, true)) { + return; + } + + $composedProperties = $validator->getComposedProperties(); + $branchJsonList = []; + $seen = []; + + foreach ($composedProperties as $index => $composedProperty) { + $branchJsonList[$index] = $this->collectBranchOwnJson($composedProperty, $seen); + } + + $enclosingHasObjectClaims = $this->declaresObjectClaimingKeyword($enclosingJson); + $enclosingHasArrayClaims = $this->declaresArrayClaimingKeyword($enclosingJson); + + foreach ($branchJsonList as $index => $branchJson) { + $somethingElseHasObjectClaims = $enclosingHasObjectClaims; + $somethingElseHasArrayClaims = $enclosingHasArrayClaims; + + foreach ($branchJsonList as $siblingIndex => $siblingJson) { + if ($siblingIndex === $index) { + continue; + } + + $somethingElseHasObjectClaims = + $somethingElseHasObjectClaims || $this->declaresObjectClaimingKeyword($siblingJson); + $somethingElseHasArrayClaims = + $somethingElseHasArrayClaims || $this->declaresArrayClaimingKeyword($siblingJson); + } + + $this->assertKeywordNotUnsupported( + $branchJson, + 'unevaluatedProperties', + $somethingElseHasObjectClaims, + $composedProperties[$index]->getBranchSchema(), + $contextName, + $index, + ); + $this->assertKeywordNotUnsupported( + $branchJson, + 'unevaluatedItems', + $somethingElseHasArrayClaims, + $composedProperties[$index]->getBranchSchema(), + $contextName, + $index, + ); + } + } + + /** + * A branch's own JSON, or - when it has no nested `Schema` of its own (true for every + * array-typed branch, and for a self/mutually-referencing `$ref` branch) - the JSON of + * whatever composition is nested directly inside it instead, recursing the same way + * `propertyHasBranchUnevaluatedItems()` does. `$seen` (keyed on file+pointer, not object + * identity - `getOrderedValidators()` returns fresh clones on every call) guards against the + * same self-referencing-schema cycle item 15 fixed there. + * + * @param array $seen + */ + private function collectBranchOwnJson(CompositionPropertyDecorator $composedProperty, array &$seen): array + { + $branchJson = $composedProperty->getBranchSchema()->getJson(); + + if ($composedProperty->getNestedSchema() !== null) { + return $branchJson; + } + + $wrappedProperty = $composedProperty->getWrappedProperty(); + $propertyKey = $wrappedProperty->getJsonSchema()->getFile() + . '#' . $wrappedProperty->getJsonSchema()->getPointer(); + + if (array_key_exists($propertyKey, $seen)) { + return $branchJson; + } + + $seen[$propertyKey] = true; + + foreach ($wrappedProperty->getOrderedValidators() as $nestedValidator) { + if (!$nestedValidator instanceof AbstractComposedPropertyValidator) { + continue; + } + + foreach ($nestedValidator->getComposedProperties() as $nestedComposedProperty) { + $branchJson = array_merge($branchJson, $this->collectBranchOwnJson($nestedComposedProperty, $seen)); + } + } + + return $branchJson; + } + + private function declaresObjectClaimingKeyword(array $json): bool + { + return array_key_exists('properties', $json) + || array_key_exists('patternProperties', $json) + || array_key_exists('additionalProperties', $json); + } + + private function declaresArrayClaimingKeyword(array $json): bool + { + return array_key_exists('items', $json) + || array_key_exists('additionalItems', $json) + || array_key_exists('contains', $json); + } + + private function assertKeywordNotUnsupported( + array $branchJson, + string $keyword, + bool $somethingElseClaims, + JsonSchema $branchSchema, + string $contextName, + int $branchIndex, + ): void { + if ( + !array_key_exists($keyword, $branchJson) + || $branchJson[$keyword] === true + || !$somethingElseClaims + ) { + return; + } + + throw new UnsupportedSchemaFeatureException( + sprintf( + "Branch #%d of the composition for '%s' declares '%s', which cannot yet see " + . 'property names or indices declared by the enclosing schema or a sibling ' + . "branch - remove '%s' from the branch, or restructure the schema so it is " + . 'declared only at the level that needs it', + $branchIndex + 1, + $contextName, + $keyword, + $keyword, + ), + $branchSchema, + ); + } + /** * Enables evaluation tracking on every schema-level composition validator and declares * the `_compositionEvaluations` cache field if any composition was activated. diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 78c24453..1278f925 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -13,6 +13,7 @@ use PHPModelGenerator\Exception\Generic\InvalidTypeException; use PHPModelGenerator\Exception\Object\UnevaluatedPropertiesException; use PHPModelGenerator\Exception\SchemaException; +use PHPModelGenerator\Exception\UnsupportedSchemaFeatureException; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use PHPModelGenerator\Tests\Fixtures\RecordingLogger; @@ -1146,44 +1147,32 @@ public function testSerializationCombinesTupleAndUnevaluatedItemsFiltersWithoutC /** * Array-side counterpart of `UnevaluatedPropertiesValidatorTest:: - * testBranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations()` - same root cause, confirmed - * to affect this side too (the object-side write-up's own "revisit trigger" left this - * unverified). A branch's own `unevaluatedItems` has no visibility into indices a *sibling* - * applicator on the same array property already claims: here, the sibling tuple `items: - * [{type: string}]` claims index 0, but the `allOf` branch's own `unevaluatedItems: false` - * has no way to see that claim - it only knows what its own (empty) local applicators - * evaluated - so `['a']` at index 0 is wrongly rejected as unevaluated even though the - * sibling tuple already validated and claimed it. + * testBranchOnlyUnevaluatedPropertiesThrowsUnsupportedSchemaFeatureException()` - same root + * cause, confirmed to affect this side too. A branch's own `unevaluatedItems` cannot see + * indices a *sibling* applicator on the same array property already claims: here, the + * sibling tuple `items: [{type: string}]` claims index 0, but the `allOf` branch's own + * `unevaluatedItems: false` has no way to see that claim - it only knows what its own + * (empty) local applicators evaluated, so `['a']` at index 0 used to be wrongly rejected as + * unevaluated even though the sibling tuple already validated and claimed it. * - * Root cause is architectural, not a call-site bug: a composition branch renders as its own, - * separately-instantiated generated class (constructed fresh from the raw array value), with - * no reference back to the enclosing property's own runtime state - * (`_evaluatedItemIndices`/`_compositionAnnotated`). Fixing this for the *sibling-claims* - * direction is harder than the enclosing-schema-declared-names case on the object side: the - * enclosing schema's own declared names are static, generation-time-known information that - * could in principle be baked into the branch's own generated validator call, but a sibling - * branch's own evaluated set can be genuinely instance-dependent (e.g. if the sibling is - * itself a composition or carries `patternProperties`), so a real fix needs the enclosing - * property to compute and pass its siblings' claims into the branch instance at - * construction time - not just a static list. See the implementation plan for the full - * writeup and the object-side companion bug this mirrors. + * Rather than silently computing a wrong "unevaluated" set, the generator now rejects the + * schema itself at generation time - see + * `UnsupportedSchemaFeatureException` and `.claude/topics/branch-unevaluated-down-propagation/` + * for why a real fix (the branch would need the enclosing property to compute and pass its + * siblings' claims into the branch instance at construction time - siblings' claims can be + * instance-dependent, unlike the object-side's enclosing-declared-*names* case, which is + * static) was deferred instead of implemented. */ - public function testBranchUnevaluatedItemsIgnoresSiblingTupleClaim(): void + public function testBranchUnevaluatedItemsThrowsUnsupportedSchemaFeatureException(): void { - $this->markTestIncomplete( - 'Bug: a composition branch\'s own unevaluatedItems does not see indices a sibling ' - . 'applicator (here, a sibling tuple items) already claims on the same array ' - . 'property - the branch\'s nested class has no visibility into claims made outside ' - . 'itself. Array-side counterpart of the object-side bug tracked in ' - . 'implementation-plan.md; see the class docblock above for the full analysis.', + $this->expectException(UnsupportedSchemaFeatureException::class); + $this->expectExceptionMessage( + "Branch #1 of the composition for 'tags' declares 'unevaluatedItems', which cannot " + . 'yet see property names or indices declared by the enclosing schema or a ' + . "sibling branch - remove 'unevaluatedItems' from the branch, or restructure the " + . 'schema so it is declared only at the level that needs it at line 12, column 9', ); - // @phpstan-ignore-next-line dead code — unreachable until the fix lands - $className = $this->generateClassFromFile('BranchUnevaluatedItemsIgnoresSiblingTupleClaim.json'); - - // Index 0 is declared and validated by the sibling tuple `items`, not by the branch - - // the branch's `unevaluatedItems: false` must still treat it as evaluated. - $accepted = new $className(['tags' => ['a']]); - $this->assertSame(['tags' => ['a']], $accepted->meta()->rawInput()); + $this->generateClassFromFile('BranchUnevaluatedItemsIgnoresSiblingTupleClaim.json', null, true); } } diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index 6c108366..a5d374f1 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -14,6 +14,7 @@ use PHPModelGenerator\Exception\Object\RequiredValueException; use PHPModelGenerator\Exception\Object\UnevaluatedPropertiesException; use PHPModelGenerator\Exception\SchemaException; +use PHPModelGenerator\Exception\UnsupportedSchemaFeatureException; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use PHPModelGenerator\Tests\Fixtures\RecordingLogger; @@ -471,56 +472,108 @@ public function testBranchLevelUnevaluatedTrueClaimsEveryKeyForTheOuterAccumulat } /** - * Deferred bug, found while auditing line-coverage on `UnevaluatedPropertiesPostProcessor`: - * a composition branch that declares `unevaluatedProperties` with no matching keyword on - * the enclosing schema must still see every property the enclosing schema (or a sibling - * branch) declares — `allOf` does not introduce a new evaluation-context boundary, so per - * spec a branch-level `unevaluatedProperties` is evaluated against the *same* annotation - * set as an outer-level one would be. + * A composition branch's own `unevaluatedProperties` cannot see property names the + * *enclosing* schema (or a sibling branch) declares — `allOf` does not introduce a new + * evaluation-context boundary, so per spec a branch-level `unevaluatedProperties` should be + * evaluated against the *same* annotation set an outer-level one would be, but nothing in + * the current architecture plumbs the enclosing schema's declared names (or a sibling + * branch's claims) through to a branch's own, separately-generated class - only the reverse + * direction (branch claims propagating up) is implemented. * - * The branch here renders as its own nested class (any branch declaring - * `unevaluatedProperties` routes through the object-instantiation path, even with no other - * keywords of its own). That nested class's `_executePostCompositionValidators()` calls - * `collectUnevaluatedKeys($modelData, [], [], [])` — an empty declared-names list, because - * the branch itself declares nothing. It has no visibility into `name`, which the - * *enclosing* schema's own `properties` declares and validates. The construction below - * should therefore succeed (`name` is evaluated by the enclosing schema), but instead - * throws `AllOfException` wrapping an `UnevaluatedPropertiesException` naming `name` as - * unevaluated — confirmed independent of whether the branch has its own `properties` - * (verified by hand against a variant with `properties: {foo}` added to the branch: the - * *same* enclosing-declared `name` is still wrongly rejected). - * - * This is the mirror image of `testBranchLevelAdditionalPropertiesFeedsEvaluatedSetThroughComposition` - * and friends above: those prove a branch's claims propagate *up* to an outer - * `unevaluatedProperties`. This is the reverse direction — an outer (or sibling-branch) - * declaration propagating *down* into a branch's own `unevaluatedProperties` — and per - * `docs/source/combinedSchemas/allOf.rst`'s "Property and item evaluation propagation" - * section (which only documents the up-propagation direction), the down direction was - * never built. - * - * Deferred: the fix touches how a composition branch's own nested class computes its - * evaluated set, not a single call site — it needs to receive (or otherwise gain - * visibility into) the declared names of the enclosing schema and any sibling branches, - * which the current architecture does not plumb through to a branch's own generated class - * at all. Tracked in implementation-plan.md's post-implementation-review list. + * Rather than silently computing a wrong "unevaluated" set (this exact shape used to reject + * `{name: "Alice"}`, even though `name` is declared and validated by the enclosing schema's + * own `properties`), the generator now rejects the schema itself at generation time with a + * distinct exception - see `.claude/topics/branch-unevaluated-down-propagation/` for why a + * real fix was deferred instead of implemented (a sound fix exists for `allOf` alone, but + * the equivalent for `anyOf`/`oneOf` can have no unique consistent answer at all when two + * sibling branches each gate their own `unevaluatedProperties` on the other's claims). */ - public function testBranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations(): void + public function testBranchOnlyUnevaluatedPropertiesThrowsUnsupportedSchemaFeatureException(): void { - $this->markTestIncomplete( - 'Bug: a composition branch\'s own unevaluatedProperties does not see properties the ' - . 'enclosing schema (or a sibling branch) declares — the branch\'s nested class has ' - . 'no visibility into names declared outside itself. See the class docblock above ' - . 'for the full analysis; tracked in implementation-plan.md.', + $this->expectException(UnsupportedSchemaFeatureException::class); + $this->expectExceptionMessage( + "Branch #1 of the composition for 'BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations' " + . "declares 'unevaluatedProperties', which cannot yet see property names or indices " + . 'declared by the enclosing schema or a sibling branch - remove ' + . "'unevaluatedProperties' from the branch, or restructure the schema so it is " + . 'declared only at the level that needs it at line 10, column 5', ); - // @phpstan-ignore-next-line dead code — unreachable until the fix lands - $className = $this->generateClassFromFile('BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json'); + $this->generateClassFromFile('BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json', null, true); + } - // 'name' is declared and validated by the enclosing schema's own `properties`, not by - // the branch — the branch's `unevaluatedProperties: false` must still treat it as - // evaluated, since allOf shares one evaluation context with its enclosing schema. - $accepted = new $className(['name' => 'Alice']); - $this->assertSame(['name' => 'Alice'], $accepted->meta()->rawInput()); + /** + * The unsupported-down-propagation guard above is driven by + * `AbstractComposedPropertyValidator::getComposedProperties()`, which every composition + * keyword's validator populates uniformly - confirms the guard actually reaches `anyOf`, + * `oneOf`, and `if`/`then`/`else` too, not just the `allOf` shape the bug was originally + * found with. Also confirms the *sibling-branch* half of the detection (as opposed to the + * enclosing-schema-declares-something half every other row here exercises): the enclosing + * schema itself declares nothing in `SiblingBranchUnsupportedDownPropagation.json` - only a + * *sibling* `allOf` branch declares `properties`. + * + * @return array + */ + public static function unsupportedDownPropagationDataProvider(): array + { + return [ + 'anyOf branch' => [ + 'AnyOfBranchUnsupportedDownPropagation.json', + "Branch #1 of the composition for 'AnyOfBranchUnsupportedDownPropagation' declares " + . "'unevaluatedProperties', which cannot yet see property names or indices " + . 'declared by the enclosing schema or a sibling branch - remove ' + . "'unevaluatedProperties' from the branch, or restructure the schema so it is " + . 'declared only at the level that needs it at line 10, column 5', + ], + 'oneOf branch' => [ + 'OneOfBranchUnsupportedDownPropagation.json', + "Branch #1 of the composition for 'OneOfBranchUnsupportedDownPropagation' declares " + . "'unevaluatedProperties', which cannot yet see property names or indices " + . 'declared by the enclosing schema or a sibling branch - remove ' + . "'unevaluatedProperties' from the branch, or restructure the schema so it is " + . 'declared only at the level that needs it at line 10, column 5', + ], + 'if/then branch' => [ + 'IfThenBranchUnsupportedDownPropagation.json', + "Branch #2 of the composition for 'IfThenBranchUnsupportedDownPropagation' declares " + . "'unevaluatedProperties', which cannot yet see property names or indices " + . 'declared by the enclosing schema or a sibling branch - remove ' + . "'unevaluatedProperties' from the branch, or restructure the schema so it is " + . 'declared only at the level that needs it at line 19, column 11', + ], + 'sibling branch declares the claim, not the enclosing schema' => [ + 'SiblingBranchUnsupportedDownPropagation.json', + "Branch #1 of the composition for 'SiblingBranchUnsupportedDownPropagation' declares " + . "'unevaluatedProperties', which cannot yet see property names or indices " + . 'declared by the enclosing schema or a sibling branch - remove ' + . "'unevaluatedProperties' from the branch, or restructure the schema so it is " + . 'declared only at the level that needs it at line 5, column 5', + ], + ]; + } + + #[DataProvider('unsupportedDownPropagationDataProvider')] + public function testUnsupportedDownPropagationThrowsAcrossCompositionKeywords( + string $schemaFile, + string $expectedMessage, + ): void { + $this->expectException(UnsupportedSchemaFeatureException::class); + $this->expectExceptionMessage($expectedMessage); + + $this->generateClassFromFile($schemaFile, null, true); + } + + /** + * `not` is deliberately exempt from the unsupported-down-propagation guard: it already + * blocks annotations from crossing its boundary in both directions by design (see + * `not.rst`) - nothing about its own outcome depends on outside annotations, so there is + * nothing for the guard to protect against here, unlike every other composition keyword. + */ + public function testNotBranchIsExemptFromUnsupportedDownPropagationGuard(): void + { + $className = $this->generateClassFromFile('NotBranchExemptFromUnsupportedDownPropagation.json'); + + $this->assertSame(['name' => 'Alice'], (new $className(['name' => 'Alice']))->meta()->rawInput()); } /** diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfBranchUnsupportedDownPropagation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfBranchUnsupportedDownPropagation.json new file mode 100644 index 00000000..4e9f0aaa --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfBranchUnsupportedDownPropagation.json @@ -0,0 +1,14 @@ +{ + "title": "AnyOfBranchUnsupportedDownPropagation", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "anyOf": [ + { + "unevaluatedProperties": false + } + ] +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json index 1a67ed5a..497aa6f3 100644 --- a/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json @@ -1,4 +1,5 @@ { + "title": "BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations", "type": "object", "properties": { "name": { diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenBranchUnsupportedDownPropagation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenBranchUnsupportedDownPropagation.json new file mode 100644 index 00000000..580b51cb --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenBranchUnsupportedDownPropagation.json @@ -0,0 +1,22 @@ +{ + "title": "IfThenBranchUnsupportedDownPropagation", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "if": { + "properties": { + "kind": { + "const": "a" + } + } + }, + "then": { + "unevaluatedProperties": false + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchExemptFromUnsupportedDownPropagation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchExemptFromUnsupportedDownPropagation.json new file mode 100644 index 00000000..d30a36aa --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchExemptFromUnsupportedDownPropagation.json @@ -0,0 +1,12 @@ +{ + "title": "NotBranchExemptFromUnsupportedDownPropagation", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "not": { + "unevaluatedProperties": false + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/OneOfBranchUnsupportedDownPropagation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/OneOfBranchUnsupportedDownPropagation.json new file mode 100644 index 00000000..ea71cebf --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/OneOfBranchUnsupportedDownPropagation.json @@ -0,0 +1,14 @@ +{ + "title": "OneOfBranchUnsupportedDownPropagation", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "oneOf": [ + { + "unevaluatedProperties": false + } + ] +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/SiblingBranchUnsupportedDownPropagation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/SiblingBranchUnsupportedDownPropagation.json new file mode 100644 index 00000000..57c8f476 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/SiblingBranchUnsupportedDownPropagation.json @@ -0,0 +1,16 @@ +{ + "title": "SiblingBranchUnsupportedDownPropagation", + "type": "object", + "allOf": [ + { + "unevaluatedProperties": false + }, + { + "properties": { + "other": { + "type": "string" + } + } + } + ] +} From 0b393c605e4e9bc10399abedd9f6d7c1a6353a66 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:46:25 +0000 Subject: [PATCH 43/51] Reject invalid unevaluated keyword values loudly and tidy touched files - unevaluatedProperties: null was silently treated as an absent keyword (isset); it now raises a SchemaException like the array-side keyword. - A non-empty JSON list as the value of unevaluatedProperties or unevaluatedItems crashed with an uncaught TypeError from the draft lookup; both factories now reject it with a SchemaException via a shared AbstractValidatorFactory::isBooleanOrSchemaObject() check. - The object-side message said "for property" but interpolated the class name; it now says "for class". Both messages carry the source location. - Remove the unused ContainsValidatorFactory import from Draft_2020_12 and the stale "Class X / @package" header docblocks on ConditionalPropertyValidator and AbstractComposedPropertyValidator. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01NocUsfMTBudWN3fmvFZg8z --- src/Draft/Draft_2020_12.php | 2 - .../AbstractComposedPropertyValidator.php | 5 -- .../ConditionalPropertyValidator.php | 5 -- .../Factory/AbstractValidatorFactory.php | 11 ++++ .../UnevaluatedItemsValidatorFactory.php | 3 +- .../UnevaluatedPropertiesValidatorFactory.php | 11 ++-- tests/Basic/UnevaluatedItemsValidatorTest.php | 66 ++++++++++++++++--- ...json => InvalidUnevaluatedItemsValue.json} | 2 +- .../InvalidUnevaluatedPropertiesType.json | 4 -- .../InvalidUnevaluatedPropertiesValue.json | 9 +++ 10 files changed, 88 insertions(+), 30 deletions(-) rename tests/Schema/UnevaluatedItemsValidatorTest/{InvalidUnevaluatedItemsType.json => InvalidUnevaluatedItemsValue.json} (75%) delete mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesType.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesValue.json diff --git a/src/Draft/Draft_2020_12.php b/src/Draft/Draft_2020_12.php index ab42d9cc..b636ea96 100644 --- a/src/Draft/Draft_2020_12.php +++ b/src/Draft/Draft_2020_12.php @@ -4,8 +4,6 @@ namespace PHPModelGenerator\Draft; -use PHPModelGenerator\Model\Validator\Factory\Arrays\ContainsValidatorFactory; - class Draft_2020_12 extends Draft_2019_09 { } diff --git a/src/Model/Validator/AbstractComposedPropertyValidator.php b/src/Model/Validator/AbstractComposedPropertyValidator.php index ebcdad98..2226a8ce 100644 --- a/src/Model/Validator/AbstractComposedPropertyValidator.php +++ b/src/Model/Validator/AbstractComposedPropertyValidator.php @@ -9,11 +9,6 @@ use PHPModelGenerator\SchemaProcessor\PostProcessor\RenderedMethod; use PHPModelGenerator\Utils\RenderHelper; -/** - * Class AbstractComposedPropertyValidator - * - * @package PHPModelGenerator\Model\Validator - */ abstract class AbstractComposedPropertyValidator extends ExtractedMethodValidator { /** @var string */ diff --git a/src/Model/Validator/ConditionalPropertyValidator.php b/src/Model/Validator/ConditionalPropertyValidator.php index a42ac107..e5cb9342 100644 --- a/src/Model/Validator/ConditionalPropertyValidator.php +++ b/src/Model/Validator/ConditionalPropertyValidator.php @@ -11,11 +11,6 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Validator\Factory\Composition\IfValidatorFactory; -/** - * Class ConditionalPropertyValidator - * - * @package PHPModelGenerator\Model\Validator - */ class ConditionalPropertyValidator extends AbstractComposedPropertyValidator { /** @var CompositionPropertyDecorator[] */ diff --git a/src/Model/Validator/Factory/AbstractValidatorFactory.php b/src/Model/Validator/Factory/AbstractValidatorFactory.php index c3b39530..1712f753 100644 --- a/src/Model/Validator/Factory/AbstractValidatorFactory.php +++ b/src/Model/Validator/Factory/AbstractValidatorFactory.php @@ -19,4 +19,15 @@ public function getKey(): ?string { return isset($this->key) ? $this->key : null; } + + /** + * True for a value that is a valid subschema: a boolean or a schema object. json_decode(..., + * true) maps the empty object `{}` and the empty array `[]` to the same empty PHP array, so + * `[]` cannot be told apart from `{}` and is accepted as the empty schema. A non-empty list is + * never a schema object and is rejected, as are scalars and null. + */ + protected function isBooleanOrSchemaObject(mixed $value): bool + { + return is_bool($value) || (is_array($value) && ($value === [] || !array_is_list($value))); + } } diff --git a/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php index 13560b78..428f40e0 100644 --- a/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php +++ b/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php @@ -42,7 +42,7 @@ public function modify( $unevaluatedItems = $json[$this->key]; - if (!is_bool($unevaluatedItems) && !is_array($unevaluatedItems)) { + if (!$this->isBooleanOrSchemaObject($unevaluatedItems)) { throw new SchemaException( sprintf( "Invalid unevaluatedItems %s for property '%s' in file %s", @@ -50,6 +50,7 @@ public function modify( $property->getName(), $propertySchema->getFile(), ), + $propertySchema, ); } diff --git a/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php index b7380382..f44c1a14 100644 --- a/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php @@ -27,21 +27,24 @@ public function modify( $json = $propertySchema->getJson(); // `unevaluatedProperties: true` is the spec default — every unevaluated key is allowed, - // so no validator is needed. Absent keyword is treated the same way. - if (!isset($json[$this->key]) || $json[$this->key] === true) { + // so no validator is needed. Absent keyword is treated the same way. array_key_exists() + // rather than isset(): an explicit `null` is an invalid value and must reach the check + // below instead of being mistaken for an absent keyword. + if (!array_key_exists($this->key, $json) || $json[$this->key] === true) { return; } $unevaluatedProperties = $json[$this->key]; - if (!is_bool($unevaluatedProperties) && !is_array($unevaluatedProperties)) { + if (!$this->isBooleanOrSchemaObject($unevaluatedProperties)) { throw new SchemaException( sprintf( - "Invalid unevaluatedProperties %s for property '%s' in file %s", + "Invalid unevaluatedProperties %s for class '%s' in file %s", str_replace("\n", '', var_export($unevaluatedProperties, true)), $schema->getClassName(), $propertySchema->getFile(), ), + $propertySchema, ); } diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 1278f925..0d713086 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -374,22 +374,64 @@ public function testDeadCodeShapesWarn(string $schemaFile, string $reason): void } /** - * Non-bool / non-object values for `unevaluatedProperties` and `unevaluatedItems` must - * fail loudly at generation time with a SchemaException. The generator never produces - * broken code for these inputs; CLAUDE.md's "Schema error handling" rule applies. + * Values that are neither a boolean nor a schema object must fail loudly at generation time + * with a SchemaException naming the offending value and the property or class carrying the keyword; + * the generator never produces broken code for these inputs. Covers the scalar shapes, an + * explicit `null` (a present-but-null keyword is invalid, not absent), and a non-empty JSON + * list (a list is not a schema object and must not reach the schema processor). * - * @return array + * @return array */ public static function invalidTypeProvider(): array { return [ 'unevaluatedItems with integer value' => [ - 'InvalidUnevaluatedItemsType.json', + 'InvalidUnevaluatedItemsValue.json', + 'unevaluatedItems', + '42', + '42', + 'property', + 'tags', + ], + 'unevaluatedItems with null value' => [ + 'InvalidUnevaluatedItemsValue.json', + 'unevaluatedItems', + 'null', + 'NULL', + 'property', + 'tags', + ], + 'unevaluatedItems with list value' => [ + 'InvalidUnevaluatedItemsValue.json', 'unevaluatedItems', + '["a"]', + "array ( 0 => 'a',)", + 'property', + 'tags', ], 'unevaluatedProperties with integer value' => [ - 'InvalidUnevaluatedPropertiesType.json', + 'InvalidUnevaluatedPropertiesValue.json', + 'unevaluatedProperties', + '42', + '42', + 'class', + '\\S+', + ], + 'unevaluatedProperties with null value' => [ + 'InvalidUnevaluatedPropertiesValue.json', + 'unevaluatedProperties', + 'null', + 'NULL', + 'class', + '\\S+', + ], + 'unevaluatedProperties with list value' => [ + 'InvalidUnevaluatedPropertiesValue.json', 'unevaluatedProperties', + '[{"type": "string"}]', + "array ( 0 => array ( 'type' => 'string', ),)", + 'class', + '\\S+', ], ]; } @@ -398,13 +440,21 @@ public static function invalidTypeProvider(): array public function testInvalidTypeForKeywordThrowsSchemaException( string $schemaFile, string $keyword, + string $jsonValue, + string $renderedValue, + string $subjectKind, + string $subjectNamePattern, ): void { + // The object-side keyword is processed against the nested class, so its message names the + // generated class (whose name carries a run-specific suffix); the array-side message names + // the array property. $this->expectException(SchemaException::class); $this->expectExceptionMessageMatches( - '/^Invalid ' . preg_quote($keyword, '/') . ' 42 for property \'\S+\' in file /', + '/^' . preg_quote("Invalid $keyword $renderedValue for $subjectKind '", '/') + . $subjectNamePattern . '\' in file \S+ at line \d+, column \d+$/', ); - $this->generateClassFromFile($schemaFile); + $this->generateClassFromFileTemplate($schemaFile, [$jsonValue], null, false); } /** diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsType.json b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsValue.json similarity index 75% rename from tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsType.json rename to tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsValue.json index 99511a5b..18ef216e 100644 --- a/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsType.json +++ b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsValue.json @@ -3,7 +3,7 @@ "properties": { "tags": { "type": "array", - "unevaluatedItems": 42 + "unevaluatedItems": %s } } } diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesType.json b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesType.json deleted file mode 100644 index 82cbb4d5..00000000 --- a/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesType.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "object", - "unevaluatedProperties": 42 -} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesValue.json b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesValue.json new file mode 100644 index 00000000..f1dbd52f --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesValue.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "child": { + "type": "object", + "unevaluatedProperties": %s + } + } +} From 0651f7bf447cb046846557cebacdbf01152b8c0b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:50:53 +0000 Subject: [PATCH 44/51] Credit explicit all-index branch applicators and roll back failed conditionals - A successful composition branch carrying `items: true`, `items: {}` or `unevaluatedItems: true` now evaluates every index for an enclosing `unevaluatedItems`, like a schema-form `items` always did. Previously such a branch credited nothing and the outer keyword over-rejected. CompositionPropertyDecorator::branchHasItemsSchema() becomes branchClaimsAllIndices(); `[]` is read as the empty schema `{}` as in the sibling-applicator path. - ConditionalComposedItem now restores `_compositionEvaluations` when the whole conditional fails, matching ComposedItem: the claims of a passing `if` condition no longer leak into the unevaluatedProperties orphan report next to a failed `then`/`else`. - Pin that a named setter re-runs a composition whose branch constrains the key only through patternProperties or a nested composition. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01NocUsfMTBudWN3fmvFZg8z --- .../Property/CompositionPropertyDecorator.php | 31 ++++++-- src/Templates/Validator/ComposedItem.phptpl | 6 +- .../Validator/ConditionalComposedItem.phptpl | 28 ++++--- tests/Basic/UnevaluatedItemsValidatorTest.php | 79 +++++++++++++++++++ .../UnevaluatedPropertiesMutabilityTest.php | 58 ++++++++++++++ .../UnevaluatedPropertiesValidatorTest.php | 56 +++++++++++++ .../AnyOfFailingBranchCreditsNothing.json | 18 +++++ .../BranchClaimsEveryIndex.json | 12 +++ .../IfBranchItemsTrue.json | 16 ++++ ...atternPropertiesConstrainsDeclaredKey.json | 16 ++++ ...ranchCompositionConstrainsDeclaredKey.json | 20 +++++ .../ConditionalFailureDropsIfClaims.json | 34 ++++++++ 12 files changed, 354 insertions(+), 20 deletions(-) create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/AnyOfFailingBranchCreditsNothing.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/BranchClaimsEveryIndex.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/IfBranchItemsTrue.json create mode 100644 tests/Schema/UnevaluatedPropertiesMutabilityTest/BranchPatternPropertiesConstrainsDeclaredKey.json create mode 100644 tests/Schema/UnevaluatedPropertiesMutabilityTest/NestedBranchCompositionConstrainsDeclaredKey.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/ConditionalFailureDropsIfClaims.json diff --git a/src/Model/Property/CompositionPropertyDecorator.php b/src/Model/Property/CompositionPropertyDecorator.php index 4fc3cf63..7757b649 100644 --- a/src/Model/Property/CompositionPropertyDecorator.php +++ b/src/Model/Property/CompositionPropertyDecorator.php @@ -208,14 +208,27 @@ public function branchEvaluationDependsOnUndeclaredKeys(): bool } /** - * True when the branch declares `items` as a schema object (not a tuple list, not a - * boolean). A schema-form `items` claims every index in the validated array. + * True when a successful branch evaluates every index of the validated array: `items` as a + * schema object, an explicit `items: true`, an empty `items` (`{}`), or an explicit + * `unevaluatedItems: true`. Each validates trivially or per index and annotates the whole + * array; only an omitted keyword produces no annotation. + * + * An empty PHP array is treated as the empty schema `{}`, never as an empty tuple `[]`: + * json_decode(..., true) maps both to the same value, and the sibling-applicator path + * (UnevaluatedItemsValidatorFactory) makes the same call. Reading it as an empty tuple would + * credit nothing and over-reject a branch the author wrote as `items: {}`. */ - public function branchHasItemsSchema(): bool + public function branchClaimsAllIndices(): bool { - $items = $this->jsonSchema->getJson()['items'] ?? null; + $branchJson = $this->jsonSchema->getJson(); + + if (($branchJson['unevaluatedItems'] ?? null) === true) { + return true; + } + + $items = $branchJson['items'] ?? null; - return is_array($items) && $items !== [] && !array_is_list($items); + return $items === true || (is_array($items) && ($items === [] || !array_is_list($items))); } /** @@ -258,7 +271,8 @@ public function branchHasContains(): bool /** * True when the branch carries at least one array-side applicator (`items`, - * `additionalItems`, `contains`) and no object-side applicators (`properties`, + * `additionalItems`, `contains`, or an `unevaluatedItems: true` annotation) and no + * object-side applicators (`properties`, * `additionalProperties`, `patternProperties`). Drives whether the composition template * writes the branch's slot into `_compositionAnnotated`. */ @@ -268,7 +282,8 @@ public function branchIsArrayKind(): bool $hasArrayApplicator = array_key_exists('items', $branchJson) || array_key_exists('additionalItems', $branchJson) - || array_key_exists('contains', $branchJson); + || array_key_exists('contains', $branchJson) + || ($branchJson['unevaluatedItems'] ?? null) === true; $hasObjectApplicator = array_key_exists('properties', $branchJson) || array_key_exists('additionalProperties', $branchJson) @@ -334,7 +349,7 @@ public function needsEvaluatedIndexAggregation(): bool * processed entirely inside its generated nested class. So a branch here never simultaneously * has a nested schema and its own composed/conditional validator; the validator, when present, * always belongs to a bare (untyped) branch and must be kept and rendered, or the nested - * composition would silently accept every value (issue #167). + * composition would silently accept every value. * * Filtering here — at render time, without mutating the wrapped property's own validator * list — keeps the exclusion branch-local. The wrapped property may be shared (via diff --git a/src/Templates/Validator/ComposedItem.phptpl b/src/Templates/Validator/ComposedItem.phptpl index 751cdf41..91d6707a 100644 --- a/src/Templates/Validator/ComposedItem.phptpl +++ b/src/Templates/Validator/ComposedItem.phptpl @@ -101,17 +101,17 @@ {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and compositionProperty.needsEvaluatedIndexAggregation() %} if ($shouldTrack) { - {% if compositionProperty.branchHasItemsSchema() %} + {% if compositionProperty.branchClaimsAllIndices() %} $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); {% endif %} - {% if not compositionProperty.branchHasItemsSchema() and compositionProperty.getBranchTupleItemsCount() %} + {% if not compositionProperty.branchClaimsAllIndices() and compositionProperty.getBranchTupleItemsCount() %} $compositionEvaluatedIndices += array_fill( 0, min({{ compositionProperty.getBranchTupleItemsCount() }}, count($originalModelData)), true, ); {% endif %} - {% if not compositionProperty.branchHasItemsSchema() and compositionProperty.branchHasNonFalseAdditionalItems() %} + {% if not compositionProperty.branchClaimsAllIndices() and compositionProperty.branchHasNonFalseAdditionalItems() %} $compositionEvaluatedIndices += array_fill( {{ compositionProperty.getBranchTupleItemsCount() }}, max(0, count($originalModelData) - {{ compositionProperty.getBranchTupleItemsCount() }}), diff --git a/src/Templates/Validator/ConditionalComposedItem.phptpl b/src/Templates/Validator/ConditionalComposedItem.phptpl index 69dd21de..65bb01d6 100644 --- a/src/Templates/Validator/ConditionalComposedItem.phptpl +++ b/src/Templates/Validator/ConditionalComposedItem.phptpl @@ -14,6 +14,9 @@ $slotKey = '{{ compositionValidator.getSlotKey() }}'; $compositionEvaluatedIndices = []; {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + $originalCompositionEvaluations = $this->_compositionEvaluations ?? []; + {% endif %} {% if generatorConfiguration.collectErrors() %} $originalErrorRegistry = $this->_errorRegistry; $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); @@ -51,17 +54,17 @@ {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and ifProperty.branchIsArrayKind() %} if (!$ifException) { - {% if ifProperty.branchHasItemsSchema() %} + {% if ifProperty.branchClaimsAllIndices() %} $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); {% endif %} - {% if not ifProperty.branchHasItemsSchema() and ifProperty.getBranchTupleItemsCount() %} + {% if not ifProperty.branchClaimsAllIndices() and ifProperty.getBranchTupleItemsCount() %} $compositionEvaluatedIndices += array_fill( 0, min({{ ifProperty.getBranchTupleItemsCount() }}, count($originalModelData)), true, ); {% endif %} - {% if not ifProperty.branchHasItemsSchema() and ifProperty.branchHasNonFalseAdditionalItems() %} + {% if not ifProperty.branchClaimsAllIndices() and ifProperty.branchHasNonFalseAdditionalItems() %} $compositionEvaluatedIndices += array_fill( {{ ifProperty.getBranchTupleItemsCount() }}, max(0, count($originalModelData) - {{ ifProperty.getBranchTupleItemsCount() }}), @@ -129,17 +132,17 @@ {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and thenProperty.branchIsArrayKind() %} if (!$thenException) { - {% if thenProperty.branchHasItemsSchema() %} + {% if thenProperty.branchClaimsAllIndices() %} $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); {% endif %} - {% if not thenProperty.branchHasItemsSchema() and thenProperty.getBranchTupleItemsCount() %} + {% if not thenProperty.branchClaimsAllIndices() and thenProperty.getBranchTupleItemsCount() %} $compositionEvaluatedIndices += array_fill( 0, min({{ thenProperty.getBranchTupleItemsCount() }}, count($originalModelData)), true, ); {% endif %} - {% if not thenProperty.branchHasItemsSchema() and thenProperty.branchHasNonFalseAdditionalItems() %} + {% if not thenProperty.branchClaimsAllIndices() and thenProperty.branchHasNonFalseAdditionalItems() %} $compositionEvaluatedIndices += array_fill( {{ thenProperty.getBranchTupleItemsCount() }}, max(0, count($originalModelData) - {{ thenProperty.getBranchTupleItemsCount() }}), @@ -210,17 +213,17 @@ {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and elseProperty.branchIsArrayKind() %} if (!$elseException) { - {% if elseProperty.branchHasItemsSchema() %} + {% if elseProperty.branchClaimsAllIndices() %} $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); {% endif %} - {% if not elseProperty.branchHasItemsSchema() and elseProperty.getBranchTupleItemsCount() %} + {% if not elseProperty.branchClaimsAllIndices() and elseProperty.getBranchTupleItemsCount() %} $compositionEvaluatedIndices += array_fill( 0, min({{ elseProperty.getBranchTupleItemsCount() }}, count($originalModelData)), true, ); {% endif %} - {% if not elseProperty.branchHasItemsSchema() and elseProperty.branchHasNonFalseAdditionalItems() %} + {% if not elseProperty.branchClaimsAllIndices() and elseProperty.branchHasNonFalseAdditionalItems() %} $compositionEvaluatedIndices += array_fill( {{ elseProperty.getBranchTupleItemsCount() }}, max(0, count($originalModelData) - {{ elseProperty.getBranchTupleItemsCount() }}), @@ -312,6 +315,13 @@ {% if compositionValidator.getSlotKey() %} $this->_compositionAnnotated[$slotKey] = $conditionalResult ? [] : $compositionEvaluatedIndices; {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + // A conditional that fails as a whole contributes no annotations: drop the claims its + // passing "if" condition already wrote, as a failed allOf/anyOf/oneOf does. + if ($conditionalResult) { + $this->_compositionEvaluations = $originalCompositionEvaluations; + } + {% endif %} return $conditionalResult; })($value) diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 0d713086..db7fb1ce 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -297,6 +297,85 @@ public function testBranchOnlyUnevaluatedItemsValidatesEveryIndex(): void new $className(['tags' => [5]]); } + /** + * A successful composition branch that carries an explicit all-index applicator evaluates + * every index of the array, exactly like the same keyword as a sibling would. `items: true`, + * `items: {}` and `unevaluatedItems: true` all validate trivially and annotate every index; + * only an omitted keyword produces no annotation. The schema-form `items` row is the + * control: it already credited every index before the explicit-`true` shapes were handled. + * + * @return array}> + */ + public static function branchAllIndexClaimProvider(): array + { + return [ + 'branch items: true' => ['{"items": true}', ['a', 5]], + 'branch items: {} (empty schema object)' => ['{"items": {}}', ['a', 5]], + 'branch unevaluatedItems: true' => ['{"unevaluatedItems": true}', ['a', 5]], + 'branch items: {schema} (control)' => ['{"items": {"type": "string"}}', ['a', 'b']], + ]; + } + + #[DataProvider('branchAllIndexClaimProvider')] + public function testBranchClaimingEveryIndexCreditsTheOuterAccumulator(string $branchJson, array $tags): void + { + $className = $this->generateClassFromFileTemplate('BranchClaimsEveryIndex.json', [$branchJson], null, false); + + $accepted = new $className(['tags' => $tags]); + $this->assertSame($tags, $accepted->getTags()); + } + + /** + * Only a branch that actually succeeded credits indices. The first anyOf branch claims every + * index through `items: true` but fails on `maxItems`, so for a two-element array the claim is + * discarded; the second branch succeeds without any applicator and credits nothing. Both + * indices stay unevaluated. A one-element array satisfies the first branch, whose claim then + * covers the index. + */ + public function testFailingBranchWithAllIndexClaimCreditsNothing(): void + { + $className = $this->generateClassFromFile('AnyOfFailingBranchCreditsNothing.json'); + + $accepted = new $className(['tags' => ['only']]); + $this->assertSame(['only'], $accepted->getTags()); + + try { + new $className(['tags' => ['first', 'second']]); + $this->fail('Expected UnevaluatedItemsException: the failed branch must not credit its indices'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#0, #1]", + $exception->getMessage(), + ); + $this->assertSame([0, 1], $exception->getUnevaluatedItems()); + } + } + + /** + * An `if` condition that passes keeps its annotations even when it only carries an + * all-index applicator: `if: {items: true, minItems: 2}` evaluates every index of a + * two-element array. When the condition fails (one element) no annotation survives and the + * absent `else` credits nothing. + */ + public function testPassingIfConditionWithAllIndexClaimCreditsTheOuterAccumulator(): void + { + $className = $this->generateClassFromFile('IfBranchItemsTrue.json'); + + $accepted = new $className(['tags' => ['a', 'b']]); + $this->assertSame(['a', 'b'], $accepted->getTags()); + + try { + new $className(['tags' => ['solo']]); + $this->fail('Expected UnevaluatedItemsException: the failed if condition credits nothing'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#0]", + $exception->getMessage(), + ); + $this->assertSame([0], $exception->getUnevaluatedItems()); + } + } + /** * `uniqueItems: true` is orthogonal to `unevaluatedItems`. uniqueItems failure has its own * exception identity and message; the unevaluated check does not get to fire for that diff --git a/tests/Basic/UnevaluatedPropertiesMutabilityTest.php b/tests/Basic/UnevaluatedPropertiesMutabilityTest.php index 4decf261..b5ddd74c 100644 --- a/tests/Basic/UnevaluatedPropertiesMutabilityTest.php +++ b/tests/Basic/UnevaluatedPropertiesMutabilityTest.php @@ -4,6 +4,7 @@ namespace PHPModelGenerator\Tests\Basic; +use PHPModelGenerator\Exception\ComposedValue\AllOfException; use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\Object\UnevaluatedPropertiesException; use PHPModelGenerator\Exception\String\MinLengthException; @@ -103,6 +104,63 @@ public function testIfThenElseFlipRollsBackAndSameValueIsNoOp(): void $this->assertSame(['mode' => 'on', 'onlyWhenOn' => 5], $object->meta()->rawInput()); } + /** + * A named setter must re-run a composition whose branch constrains the written key without + * declaring it in the branch's `properties`. Two shapes on separate generated classes, each + * with an unconstrained outer property the branch narrows to integers: + * - the branch carries only `patternProperties: {"^x-": {type: integer}}`, so the key + * `x-a` is reachable by pattern alone; + * - the narrowing `properties.n` sits in a composition nested inside the branch, so the + * key is declared only two composition levels down. + * A setter that skipped the composition would commit a string the constructor rejects; the + * rejected write must leave the model reconstructible from its own raw input. + */ + public function testSetterRerunsCompositionForKeysConstrainedOnlyThroughBranchInternals(): void + { + $compositionMessage = <<<'MSG' + Invalid value for '%s' declined by composition constraint + Requires to match all composition elements but matched 0 elements + MSG; + + $patternClassName = $this->generateClassFromFile( + 'BranchPatternPropertiesConstrainsDeclaredKey.json', + $this->defaultConfig(), + ); + $patternObject = new $patternClassName(['x-a' => 1]); + + $patternObject->setXA(5); + $this->assertSame(['x-a' => 5], $patternObject->meta()->rawInput()); + + try { + $patternObject->setXA('not-an-integer'); + $this->fail('Expected setXA to be rejected by the branch patternProperties'); + } catch (AllOfException $exception) { + $this->assertSame(sprintf($compositionMessage, $patternClassName), $exception->getMessage()); + } + $this->assertSame(['x-a' => 5], $patternObject->meta()->rawInput()); + $rebuiltPatternObject = new $patternClassName($patternObject->meta()->rawInput()); + $this->assertSame(['x-a' => 5], $rebuiltPatternObject->meta()->rawInput()); + + $nestedClassName = $this->generateClassFromFile( + 'NestedBranchCompositionConstrainsDeclaredKey.json', + $this->defaultConfig(), + ); + $nestedObject = new $nestedClassName(['n' => 1]); + + $nestedObject->setN(5); + $this->assertSame(['n' => 5], $nestedObject->meta()->rawInput()); + + try { + $nestedObject->setN('not-an-integer'); + $this->fail('Expected setN to be rejected by the nested branch composition'); + } catch (AllOfException $exception) { + $this->assertSame(sprintf($compositionMessage, $nestedClassName), $exception->getMessage()); + } + $this->assertSame(['n' => 5], $nestedObject->meta()->rawInput()); + $rebuiltNestedObject = new $nestedClassName($nestedObject->meta()->rawInput()); + $this->assertSame(['n' => 5], $rebuiltNestedObject->meta()->rawInput()); + } + /** * anyOf with two branches that both claim `shared`. After setKind('secondary') branch 0 * (constrained by `const: primary`) fails but branch 1 (any string kind) keeps diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index a5d374f1..de7e51f5 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -415,6 +415,62 @@ public function testNestedUnevaluatedInBranchPropagatesClaimsToOuter(): void } } + /** + * A conditional whose `then` branch fails as a whole contributes no annotations — including + * the claims of its passing `if` condition — exactly like a failed `allOf`. With a stale `if` + * claim, `marker` would be credited to the outer accumulator and silently vanish from the + * orphan report that error collection prints next to the conditional failure. + * + * Three inputs on the same generated class (collect-errors mode): + * - `{marker: true, other: 1}`: `if` passes, `then` fails on the missing `x`; both `marker` + * (claimed only by the failed conditional) and `other` are orphans; + * - `{other: 1}`: `if` fails and `else` fails on the missing `y`; only `other` is an orphan; + * - `{marker: true, x: 1}`: the conditional succeeds and credits `marker` and `x`. + */ + public function testFailedConditionalDropsTheClaimsOfItsPassingIfCondition(): void + { + $className = $this->generateClassFromFile( + 'ConditionalFailureDropsIfClaims.json', + (new GeneratorConfiguration())->setCollectErrors(true), + ); + + $accepted = new $className(['marker' => true, 'x' => 1]); + $this->assertSame(['marker' => true, 'x' => 1], $accepted->meta()->rawInput()); + + try { + new $className(['marker' => true, 'other' => 1]); + $this->fail('Expected the failed then branch to reject the input'); + } catch (ErrorRegistryException $registry) { + $this->assertSame( + <<getMessage(), + ); + } + + try { + new $className(['other' => 1]); + $this->fail('Expected the failed else branch to reject the input'); + } catch (ErrorRegistryException $registry) { + $this->assertSame( + <<getMessage(), + ); + } + } + /** * A composition branch that declares its own `additionalProperties: {schema}` contributes * the keys it validated against that schema to the outer accumulator. Two assertions on the diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/AnyOfFailingBranchCreditsNothing.json b/tests/Schema/UnevaluatedItemsValidatorTest/AnyOfFailingBranchCreditsNothing.json new file mode 100644 index 00000000..b928e1fc --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/AnyOfFailingBranchCreditsNothing.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "anyOf": [ + { + "items": true, + "maxItems": 1 + }, + { + "minItems": 1 + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/BranchClaimsEveryIndex.json b/tests/Schema/UnevaluatedItemsValidatorTest/BranchClaimsEveryIndex.json new file mode 100644 index 00000000..3e1f72e3 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/BranchClaimsEveryIndex.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + %s + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/IfBranchItemsTrue.json b/tests/Schema/UnevaluatedItemsValidatorTest/IfBranchItemsTrue.json new file mode 100644 index 00000000..6cf00b37 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/IfBranchItemsTrue.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "if": { + "items": true, + "minItems": 2 + }, + "then": { + "maxItems": 3 + }, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/BranchPatternPropertiesConstrainsDeclaredKey.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/BranchPatternPropertiesConstrainsDeclaredKey.json new file mode 100644 index 00000000..5c6d849a --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/BranchPatternPropertiesConstrainsDeclaredKey.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "x-a": {} + }, + "allOf": [ + { + "patternProperties": { + "^x-": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/NestedBranchCompositionConstrainsDeclaredKey.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/NestedBranchCompositionConstrainsDeclaredKey.json new file mode 100644 index 00000000..2cc104d1 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/NestedBranchCompositionConstrainsDeclaredKey.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "n": {} + }, + "allOf": [ + { + "allOf": [ + { + "properties": { + "n": { + "type": "integer" + } + } + } + ] + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/ConditionalFailureDropsIfClaims.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/ConditionalFailureDropsIfClaims.json new file mode 100644 index 00000000..8ad7d157 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/ConditionalFailureDropsIfClaims.json @@ -0,0 +1,34 @@ +{ + "type": "object", + "if": { + "properties": { + "marker": { + "const": true + } + }, + "required": [ + "marker" + ] + }, + "then": { + "properties": { + "x": { + "type": "integer" + } + }, + "required": [ + "x" + ] + }, + "else": { + "properties": { + "y": { + "type": "integer" + } + }, + "required": [ + "y" + ] + }, + "unevaluatedProperties": false +} From b35ab7b93e817b0d724628b130d344ba0536a132 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:55:48 +0000 Subject: [PATCH 45/51] Make extracted-method names deterministic per generated class ExtractedMethodValidator and the composition validators derived their method names from md5(json . spl_object_id($this)). The object id shifts whenever an unrelated schema was processed earlier in the run, so adding or removing one schema file renamed the _validate*_* and _getModifiedValues_* methods of other classes - spurious diffs for users who commit generated code. Names now come from Schema::reserveMethodName(): the first request for a candidate keeps it, repeats get a numeric suffix, scoped to the class being generated. Validators that do not collide get exactly the object-id-free name format again; identical validators on one property (two compositions, identical branches) still receive distinct methods. Subset validators derive their helper name from the original's. Adds regression tests: regenerating is byte-identical, an unrelated schema processed first leaves other classes unchanged, and the reservation order/scoping is pinned. Also drops the stale Class/@package docblocks and a restating constructor docblock from the touched validators. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01NocUsfMTBudWN3fmvFZg8z --- src/Model/Schema.php | 19 ++ .../AbstractComposedPropertyValidator.php | 18 +- src/Model/Validator/ArrayItemValidator.php | 8 +- .../Validator/ComposedPropertyValidator.php | 15 +- .../ConditionalPropertyValidator.php | 3 +- .../Validator/ExtractedMethodValidator.php | 15 +- .../Basic/GeneratedOutputDeterminismTest.php | 168 ++++++++++++++++++ .../AAAUnrelated.json | 67 +++++++ .../Primary.json | 88 +++++++++ 9 files changed, 379 insertions(+), 22 deletions(-) create mode 100644 tests/Basic/GeneratedOutputDeterminismTest.php create mode 100644 tests/Schema/GeneratedOutputDeterminismTest/AAAUnrelated.json create mode 100644 tests/Schema/GeneratedOutputDeterminismTest/Primary.json diff --git a/src/Model/Schema.php b/src/Model/Schema.php index 55bc5bd7..74f8c585 100644 --- a/src/Model/Schema.php +++ b/src/Model/Schema.php @@ -81,6 +81,9 @@ class Schema */ private array $accessorCacheProperties = []; + /** @var array Number of times each candidate method name has been reserved */ + private array $reservedMethodNames = []; + private PropertyMerger $propertyMerger; /** @@ -382,6 +385,22 @@ public function getMethods(): array return $this->methods; } + /** + * Reserve a method name unique within this class. The first reservation of a candidate keeps + * it unchanged; each further reservation of the same candidate gets a numeric suffix + * (`_2`, `_3`, ...). Names are therefore a function of the candidates requested for this + * class and their order only - never of object identity or of other classes generated in the + * same run - so regenerating an unchanged schema reproduces the same names. + */ + public function reserveMethodName(string $candidate): string + { + $this->reservedMethodNames[$candidate] = ($this->reservedMethodNames[$candidate] ?? 0) + 1; + + return $this->reservedMethodNames[$candidate] === 1 + ? $candidate + : $candidate . '_' . $this->reservedMethodNames[$candidate]; + } + public function hasMethod(string $methodKey): bool { return isset($this->methods[$methodKey]); diff --git a/src/Model/Validator/AbstractComposedPropertyValidator.php b/src/Model/Validator/AbstractComposedPropertyValidator.php index 2226a8ce..db157e1f 100644 --- a/src/Model/Validator/AbstractComposedPropertyValidator.php +++ b/src/Model/Validator/AbstractComposedPropertyValidator.php @@ -5,6 +5,8 @@ namespace PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Property\CompositionPropertyDecorator; +use PHPModelGenerator\Model\Property\PropertyInterface; +use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\Validator\Factory\Composition\NotValidatorFactory; use PHPModelGenerator\SchemaProcessor\PostProcessor\RenderedMethod; use PHPModelGenerator\Utils\RenderHelper; @@ -79,9 +81,21 @@ public function isNotComposition(): bool return $this->compositionProcessor === NotValidatorFactory::class; } - protected function initModifiedValuesMethod(): void + /** + * Names the helper method that computes branch-default values. Must run before the parent + * constructor, which receives the name as a template value. The name is reserved on the + * class so two composition validators on the same property never share a helper, without + * depending on object identity (see ExtractedMethodValidator for why that is avoided). + */ + protected function initModifiedValuesMethod(Schema $schema, PropertyInterface $property): void { - $this->modifiedValuesMethod = '_getModifiedValues_' . substr(md5((string) spl_object_id($this)), 0, 5); + $this->modifiedValuesMethod = $schema->reserveMethodName( + '_getModifiedValues_' . substr( + md5($property->getAttribute() . json_encode($property->getJsonSchema()->getJson())), + 0, + 5, + ), + ); } /** diff --git a/src/Model/Validator/ArrayItemValidator.php b/src/Model/Validator/ArrayItemValidator.php index fb5d9693..026fae5c 100644 --- a/src/Model/Validator/ArrayItemValidator.php +++ b/src/Model/Validator/ArrayItemValidator.php @@ -14,19 +14,12 @@ use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; -/** - * Class ArrayItemValidator - * - * @package PHPModelGenerator\Model\Validator - */ class ArrayItemValidator extends ExtractedMethodValidator { private readonly string $variableSuffix; private readonly PropertyInterface $nestedProperty; /** - * ArrayItemValidator constructor. - * * @throws SchemaException */ public function __construct( @@ -67,6 +60,7 @@ public function __construct( ], InvalidItemException::class, ["&\$invalidItems{$this->variableSuffix}"], + $schema, ); } diff --git a/src/Model/Validator/ComposedPropertyValidator.php b/src/Model/Validator/ComposedPropertyValidator.php index 277cbd08..fa95cf29 100644 --- a/src/Model/Validator/ComposedPropertyValidator.php +++ b/src/Model/Validator/ComposedPropertyValidator.php @@ -10,11 +10,6 @@ use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Utils\RenderHelper; -/** - * Class ComposedPropertyValidator - * - * @package PHPModelGenerator\Model\Validator - */ class ComposedPropertyValidator extends AbstractComposedPropertyValidator { public function __construct( @@ -25,11 +20,11 @@ public function __construct( string $exceptionClass, array $validatorVariables, ) { - $this->initModifiedValuesMethod(); - $this->isResolved = true; - $schema = $validatorVariables['schema']; + $this->initModifiedValuesMethod($schema, $property); + $this->isResolved = true; + parent::__construct( $generatorConfiguration, $property, @@ -71,6 +66,7 @@ public function __construct( ]), $exceptionClass, ['&$succeededCompositionElements', '&$compositionErrorCollection'], + $schema, ); $this->compositionProcessor = $compositionProcessor; @@ -131,8 +127,7 @@ public function createSubsetValidator(array $branchIndices, string $methodSuffix // Regenerate the modifiedValuesMethod name so the subset validator's helper // method is distinct from the original's. - $subsetValidator->modifiedValuesMethod = - '_getModifiedValues_' . substr(md5((string) spl_object_id($subsetValidator)), 0, 5); + $subsetValidator->modifiedValuesMethod = $this->modifiedValuesMethod . $methodSuffix; $subsetValidator->composedProperties = $filteredProperties; $subsetValidator->templateValues = array_merge($this->templateValues, [ diff --git a/src/Model/Validator/ConditionalPropertyValidator.php b/src/Model/Validator/ConditionalPropertyValidator.php index e5cb9342..41be6702 100644 --- a/src/Model/Validator/ConditionalPropertyValidator.php +++ b/src/Model/Validator/ConditionalPropertyValidator.php @@ -26,7 +26,7 @@ public function __construct( array $conditionBranches, array $validatorVariables, ) { - $this->initModifiedValuesMethod(); + $this->initModifiedValuesMethod($validatorVariables['schema'], $property); $this->isResolved = true; parent::__construct( @@ -36,6 +36,7 @@ public function __construct( array_merge($validatorVariables, ['modifiedValuesMethod' => $this->modifiedValuesMethod]), ConditionalException::class, ['&$ifException', '&$thenException', '&$elseException'], + $validatorVariables['schema'], ); $this->compositionProcessor = IfValidatorFactory::class; diff --git a/src/Model/Validator/ExtractedMethodValidator.php b/src/Model/Validator/ExtractedMethodValidator.php index 21f7fdb7..5ac03c3d 100644 --- a/src/Model/Validator/ExtractedMethodValidator.php +++ b/src/Model/Validator/ExtractedMethodValidator.php @@ -9,6 +9,7 @@ use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Model\MethodInterface; use PHPModelGenerator\Model\Property\PropertyInterface; +use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Utils\RenderHelper; /** @@ -26,14 +27,24 @@ public function __construct( array $templateValues, string $exceptionClass, array $exceptionParams = [], + ?Schema $schema = null, ) { - $this->extractedMethodName = sprintf( + $methodName = sprintf( '_validate%s_%s_%s', str_replace(' ', '', ucfirst($property->getAttribute())), str_replace('Validator', '', substr(strrchr(static::class, '\\'), 1)), - md5(json_encode($property->getJsonSchema()->getJson()) . (string) spl_object_id($this)), + md5(json_encode($property->getJsonSchema()->getJson())), ); + // Two validators of the same kind on the same attribute with identical schema JSON (two + // compositions on one property, identical anyOf branches, ...) hash to the same name, and + // a class registers each name once - the second validator would silently reuse the first + // one's method body. The class-scoped reservation separates them deterministically. + // Rejected: mixing spl_object_id() into the hash - it separates them too, but the id shifts + // whenever an unrelated schema was processed earlier in the run, renaming the extracted + // methods of classes that did not change. + $this->extractedMethodName = $schema?->reserveMethodName($methodName) ?? $methodName; + parent::__construct($property, $template, $templateValues, $exceptionClass, $exceptionParams); } diff --git a/tests/Basic/GeneratedOutputDeterminismTest.php b/tests/Basic/GeneratedOutputDeterminismTest.php new file mode 100644 index 00000000..3e90271f --- /dev/null +++ b/tests/Basic/GeneratedOutputDeterminismTest.php @@ -0,0 +1,168 @@ +workDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'PHPModelGeneratorDeterminism_' . uniqid(); + mkdir($this->workDirectory, 0777, true); + } + + protected function tearDown(): void + { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->workDirectory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($this->workDirectory); + + parent::tearDown(); + } + + /** + * @param string[] $schemaFiles fixture file names to copy into the run's input directory + * + * @return array generated file contents keyed by file name + */ + private function generate(string $runName, array $schemaFiles): array + { + $inputDirectory = $this->workDirectory . DIRECTORY_SEPARATOR . $runName . '_in'; + $outputDirectory = $this->workDirectory . DIRECTORY_SEPARATOR . $runName . '_out'; + mkdir($inputDirectory); + mkdir($outputDirectory); + + foreach ($schemaFiles as $schemaFile) { + copy( + __DIR__ . '/../Schema/GeneratedOutputDeterminismTest/' . $schemaFile, + $inputDirectory . DIRECTORY_SEPARATOR . $schemaFile, + ); + } + + // The generator loads every class it renders, so each run needs a namespace of its own; + // the prefix is normalised away before the runs are compared. + $namespacePrefix = 'Determinism' . ucfirst(str_replace('_', '', $runName)); + + $generatedFiles = (new ModelGenerator( + (new GeneratorConfiguration()) + ->setNamespacePrefix($namespacePrefix) + ->setLogger(new NullLogger()), + ))->generateModels(new RecursiveDirectoryProvider($inputDirectory), $outputDirectory); + + $contents = []; + foreach ($generatedFiles as $generatedFile) { + $contents[basename($generatedFile)] = str_replace( + $namespacePrefix, + 'Determinism', + file_get_contents($generatedFile), + ); + } + ksort($contents); + + return $contents; + } + + /** + * Two separate generation runs over the same schema set produce identical files. + */ + public function testRegeneratingTheSameSchemasIsByteIdentical(): void + { + $firstRun = $this->generate('first', ['Primary.json', 'AAAUnrelated.json']); + $secondRun = $this->generate('second', ['Primary.json', 'AAAUnrelated.json']); + + $this->assertNotEmpty($firstRun); + $this->assertSame($firstRun, $secondRun); + } + + /** + * Adding an unrelated schema file - processed before the primary one - allocates objects and + * shifts every object identity that follows. The primary schema's generated classes must not + * notice: a name derived from object identity would rename its extracted methods and show up + * as a spurious diff in users' committed code. + */ + public function testAddingAnUnrelatedSchemaDoesNotChangeOtherGeneratedClasses(): void + { + $alone = $this->generate('alone', ['Primary.json']); + $withUnrelated = $this->generate('with_unrelated', ['AAAUnrelated.json', 'Primary.json']); + + $this->assertNotEmpty($alone); + + foreach ($alone as $fileName => $contents) { + $this->assertArrayHasKey($fileName, $withUnrelated); + $this->assertSame($contents, $withUnrelated[$fileName], "Generated file $fileName changed"); + } + } + + /** + * Method names are reserved per generated class: the first request for a name keeps it + * unchanged, later requests for the same name get a numeric suffix, and reservations in one + * class never influence another. + */ + public function testMethodNameReservationIsScopedToTheSchemaAndOrderedByRequest(): void + { + $firstSchema = $this->createSchema(); + $secondSchema = $this->createSchema(); + + $this->assertSame( + '_validateTags_ArrayItem_abc', + $firstSchema->reserveMethodName('_validateTags_ArrayItem_abc'), + ); + $this->assertSame( + '_validateTags_ArrayItem_abc_2', + $firstSchema->reserveMethodName('_validateTags_ArrayItem_abc'), + ); + $this->assertSame( + '_validateTags_ArrayItem_abc_3', + $firstSchema->reserveMethodName('_validateTags_ArrayItem_abc'), + ); + $this->assertSame( + '_validateTags_ArrayItem_other', + $firstSchema->reserveMethodName('_validateTags_ArrayItem_other'), + ); + + $this->assertSame( + '_validateTags_ArrayItem_abc', + $secondSchema->reserveMethodName('_validateTags_ArrayItem_abc'), + ); + } + + private function createSchema(): Schema + { + return new Schema( + $this->workDirectory . DIRECTORY_SEPARATOR . 'SchemaClassName.php', + 'SchemaClassNamespace', + 'SchemaClassName', + new JsonSchema('schema.json', []), + ); + } +} diff --git a/tests/Schema/GeneratedOutputDeterminismTest/AAAUnrelated.json b/tests/Schema/GeneratedOutputDeterminismTest/AAAUnrelated.json new file mode 100644 index 00000000..067cea48 --- /dev/null +++ b/tests/Schema/GeneratedOutputDeterminismTest/AAAUnrelated.json @@ -0,0 +1,67 @@ +{ + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "type": "integer" + }, + "allOf": [ + { + "maxItems": 3 + }, + { + "minItems": 1 + } + ], + "oneOf": [ + { + "maxItems": 1 + }, + { + "minItems": 2 + } + ] + }, + "nested": { + "type": "object", + "properties": { + "p": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "q": { + "type": "string" + } + } + }, + { + "properties": { + "r": { + "type": "string" + } + } + } + ] + } + }, + "anyOf": [ + { + "properties": { + "s": { + "type": "string" + } + } + }, + { + "properties": { + "t": { + "type": "string" + } + } + } + ] +} diff --git a/tests/Schema/GeneratedOutputDeterminismTest/Primary.json b/tests/Schema/GeneratedOutputDeterminismTest/Primary.json new file mode 100644 index 00000000..89ee1973 --- /dev/null +++ b/tests/Schema/GeneratedOutputDeterminismTest/Primary.json @@ -0,0 +1,88 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "allOf": [ + { + "maxItems": 10 + } + ], + "anyOf": [ + { + "minItems": 1 + }, + { + "maxItems": 0 + } + ] + }, + "child": { + "type": "object", + "properties": { + "x": { + "type": "integer" + } + }, + "if": { + "properties": { + "x": { + "const": 1 + } + } + }, + "then": { + "required": [ + "x" + ] + } + } + }, + "allOf": [ + { + "properties": { + "c": { + "type": "string" + } + } + }, + { + "properties": { + "d": { + "type": "integer" + } + } + } + ], + "anyOf": [ + { + "properties": { + "e": { + "type": "string" + } + } + }, + { + "properties": { + "f": { + "type": "string" + } + } + } + ], + "oneOf": [ + { + "required": [ + "tags" + ] + }, + { + "required": [ + "c" + ] + } + ] +} From b1690f302b3885a63ae55296e98eed87af4d9f2e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:58:37 +0000 Subject: [PATCH 46/51] Resolve _getEvaluatedProperties() field existence at render time The generated _getEvaluatedProperties() probed for _patternProperties and _evaluatedPropertyKeys with property_exists() at runtime although the generator already knows, when the method is rendered (every process() runs before the first render()), which of the two fields the branch class declares. The merge steps are now emitted only for fields that exist. The method is also rendered with the generated-code conventions of the other methods (column-0 body indented by the model template, short array syntax) instead of multi-line var_export output and a deeply indented literal. Comments in the touched post processor and test classes no longer point at working notes outside the repository; the rationale is spelled out in place. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01NocUsfMTBudWN3fmvFZg8z --- .../UnevaluatedPropertiesPostProcessor.php | 88 ++++++++++++------- tests/Basic/UnevaluatedItemsValidatorTest.php | 11 ++- .../UnevaluatedPropertiesValidatorTest.php | 80 ++++++++++++++++- ...BranchWithPatternAndUnevaluatedSchema.json | 21 +++++ .../BranchesWithoutEvaluationFields.json | 20 +++++ 5 files changed, 176 insertions(+), 44 deletions(-) create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/BranchWithPatternAndUnevaluatedSchema.json create mode 100644 tests/Schema/UnevaluatedPropertiesValidatorTest/BranchesWithoutEvaluationFields.json diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php index 81647eab..28d47b57 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -19,6 +19,7 @@ use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; use PHPModelGenerator\Traits\CompositionEvaluationTrait; +use PHPModelGenerator\Utils\RenderHelper; /** * Detects whether unevaluatedProperties or unevaluatedItems is reachable from a schema and, @@ -104,12 +105,11 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu * class with no visibility into the enclosing schema's own declarations or a sibling * branch's claims (only the reverse direction - branch claims propagating up - is * implemented). Silently computing a wrong "unevaluated" set would either reject valid - * input or accept invalid input, so this fails loudly at generation time instead - see - * `.claude/topics/branch-unevaluated-down-propagation/` for the full design discussion, - * including why a general fix was rejected as either unsound (anyOf/oneOf: two sibling - * branches each gating on the other's claims can have no unique consistent answer at all, - * not just an expensive one to compute) or out of proportion to a pattern of unknown - * real-world frequency (allOf, where a fix is sound but non-trivial). + * input or accept invalid input, so this fails loudly at generation time instead. A general + * fix was rejected as either unsound (anyOf/oneOf: two sibling branches each gating on the + * other's claims can have no unique consistent answer at all, not just an expensive one to + * compute) or out of proportion to a pattern of unknown real-world frequency (allOf, where + * a fix is sound but non-trivial). * * `true` is exempt: it never rejects anything regardless of what the branch believes is * evaluated, so the gap has no observable effect on validation outcomes. `not` is exempt @@ -614,34 +614,54 @@ public function getCode(): string ), )); - return sprintf( - ' - #[Internal] - public function _getEvaluatedProperties(): array - { - $evaluated = []; - foreach (%s as $propName) { - if (array_key_exists($propName, $this->_rawModelDataInput)) { - $evaluated[$propName] = true; - } - } - // Keys matched by this schema\'s own patternProperties (with passing - // values) are evaluated too, so an enclosing schema must see them. Only the - // keys matter (the result is array_keys($evaluated)), so union the maps. - if (property_exists($this, "_patternProperties")) { - foreach ($this->_patternProperties as $patternMatches) { - $evaluated += $patternMatches; - } - } - // Keys evaluated by this schema\'s own unevaluatedProperties: {schema} - // validator are tracked so an enclosing schema can see them. - if (property_exists($this, "_evaluatedPropertyKeys")) { - $evaluated += $this->_evaluatedPropertyKeys; - } - return array_keys($evaluated); - }', - var_export($declaredPropertyNames, true), - ); + // Both fields are added by post processors, and RenderQueue::execute() runs every + // process() before the first render(), so by now the nested schema's final field + // set is known. Emitting a runtime property_exists() guard instead would leave + // generated code probing for a field the generator already knows is absent. + $lines = [ + '#[Internal]', + 'public function _getEvaluatedProperties(): array', + '{', + ' $evaluated = [];', + ' foreach (' . RenderHelper::varExportArray($declaredPropertyNames) . ' as $declaredName) {', + ' if (array_key_exists($declaredName, $this->_rawModelDataInput)) {', + ' $evaluated[$declaredName] = true;', + ' }', + ' }', + ]; + + if ($this->declaresInternalProperty('patternProperties')) { + // Keys matched by this schema's own patternProperties (with passing values) are + // evaluated too, so an enclosing schema must see them. Only the keys matter + // (the result is array_keys($evaluated)), so the maps are unioned. + array_push( + $lines, + ' foreach ($this->_patternProperties as $patternMatches) {', + ' $evaluated += $patternMatches;', + ' }', + ); + } + + if ($this->declaresInternalProperty('evaluatedPropertyKeys')) { + // Keys evaluated by this schema's own unevaluatedProperties: {schema} validator + // are tracked so an enclosing schema can see them. + $lines[] = ' $evaluated += $this->_evaluatedPropertyKeys;'; + } + + array_push($lines, '', ' return array_keys($evaluated);', '}'); + + return implode("\n", $lines); + } + + private function declaresInternalProperty(string $propertyName): bool + { + foreach ($this->nestedSchema->getProperties() as $property) { + if ($property->isInternal() && $property->getName() === $propertyName) { + return true; + } + } + + return false; } }; } diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index db7fb1ce..037370fd 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -1285,12 +1285,11 @@ public function testSerializationCombinesTupleAndUnevaluatedItemsFiltersWithoutC * unevaluated even though the sibling tuple already validated and claimed it. * * Rather than silently computing a wrong "unevaluated" set, the generator now rejects the - * schema itself at generation time - see - * `UnsupportedSchemaFeatureException` and `.claude/topics/branch-unevaluated-down-propagation/` - * for why a real fix (the branch would need the enclosing property to compute and pass its - * siblings' claims into the branch instance at construction time - siblings' claims can be - * instance-dependent, unlike the object-side's enclosing-declared-*names* case, which is - * static) was deferred instead of implemented. + * schema itself at generation time with an `UnsupportedSchemaFeatureException`. A real fix + * was deferred instead of implemented: the branch would need the enclosing property to + * compute and pass its siblings' claims into the branch instance at construction time, and + * those claims can be instance-dependent, unlike the object-side's enclosing-declared-*names* + * case, which is static. */ public function testBranchUnevaluatedItemsThrowsUnsupportedSchemaFeatureException(): void { diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php index de7e51f5..15ea8eca 100644 --- a/tests/Basic/UnevaluatedPropertiesValidatorTest.php +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -21,6 +21,7 @@ use PHPModelGenerator\Tests\Support\ApplicableDrafts; use PHPModelGenerator\Tests\Support\JsonSchemaDraft; use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionClass; /** * Verifies the runtime behaviour of `unevaluatedProperties`: keys not claimed by `properties`, @@ -471,6 +472,77 @@ public function testFailedConditionalDropsTheClaimsOfItsPassingIfCondition(): vo } } + /** + * The `_getEvaluatedProperties()` method generated on a branch class merges in the branch's + * `_patternProperties` and `_evaluatedPropertyKeys` fields. Whether the class declares each + * field is known when the method is rendered, so the merge steps are emitted only for fields + * that exist - no runtime `property_exists()` probing in the generated code. Four branch + * shapes on three generated classes: + * - a branch with neither field merges nothing beyond its declared names; + * - a branch with `patternProperties` only merges the pattern matches; + * - a branch with `unevaluatedProperties: {schema}` only merges the evaluated keys; + * - a branch with both merges both. + */ + public function testEvaluatedPropertiesMethodMergesOnlyTheFieldsTheBranchClassDeclares(): void + { + $plainClassName = $this->generateClassFromFile('BranchesWithoutEvaluationFields.json'); + [$declaredOnlySource, $patternOnlySource] = array_map( + fn(string $nestedClassName): string => $this->getEvaluatedPropertiesMethodSource($nestedClassName), + $this->resolveNestedClassName($plainClassName, 2), + ); + + $this->assertStringNotContainsString('property_exists', $declaredOnlySource); + $this->assertStringNotContainsString('_patternProperties', $declaredOnlySource); + $this->assertStringNotContainsString('_evaluatedPropertyKeys', $declaredOnlySource); + + $this->assertStringNotContainsString('property_exists', $patternOnlySource); + $this->assertStringContainsString('$this->_patternProperties', $patternOnlySource); + $this->assertStringNotContainsString('_evaluatedPropertyKeys', $patternOnlySource); + + $evaluatedKeysClassName = $this->generateClassFromFile('NestedUnevaluatedInBranchPropagatesClaims.json'); + $evaluatedKeysSource = $this->getEvaluatedPropertiesMethodSource( + $this->resolveNestedClassName($evaluatedKeysClassName), + ); + + $this->assertStringNotContainsString('property_exists', $evaluatedKeysSource); + $this->assertStringNotContainsString('_patternProperties', $evaluatedKeysSource); + $this->assertStringContainsString('$this->_evaluatedPropertyKeys', $evaluatedKeysSource); + + $bothClassName = $this->generateClassFromFile('BranchWithPatternAndUnevaluatedSchema.json'); + $bothSource = $this->getEvaluatedPropertiesMethodSource($this->resolveNestedClassName($bothClassName)); + + $this->assertStringNotContainsString('property_exists', $bothSource); + $this->assertStringContainsString('$this->_patternProperties', $bothSource); + $this->assertStringContainsString('$this->_evaluatedPropertyKeys', $bothSource); + + // The unconditional merge must stay correct at runtime: a key matched by the branch's + // pattern and one evaluated by its unevaluatedProperties schema both reach the outer. + $accepted = new $bothClassName(['foo' => 'hi', 'x-a' => 'v', 'extra' => 5]); + $this->assertSame(['foo' => 'hi', 'x-a' => 'v', 'extra' => 5], $accepted->meta()->rawInput()); + } + + /** + * @return string the source of `_getEvaluatedProperties()` as rendered into the nested class + */ + private function getEvaluatedPropertiesMethodSource(string $nestedClassName): string + { + $source = file_get_contents( + (new ReflectionClass($this->lastGeneratedNamespacePrefix . $nestedClassName))->getFileName(), + ); + + $this->assertSame( + 1, + preg_match( + '/function _getEvaluatedProperties\(\): array.*?return array_keys\(\$evaluated\);/s', + $source, + $matches, + ), + "Nested class $nestedClassName must declare _getEvaluatedProperties()", + ); + + return $matches[0]; + } + /** * A composition branch that declares its own `additionalProperties: {schema}` contributes * the keys it validated against that schema to the outer accumulator. Two assertions on the @@ -539,10 +611,10 @@ public function testBranchLevelUnevaluatedTrueClaimsEveryKeyForTheOuterAccumulat * Rather than silently computing a wrong "unevaluated" set (this exact shape used to reject * `{name: "Alice"}`, even though `name` is declared and validated by the enclosing schema's * own `properties`), the generator now rejects the schema itself at generation time with a - * distinct exception - see `.claude/topics/branch-unevaluated-down-propagation/` for why a - * real fix was deferred instead of implemented (a sound fix exists for `allOf` alone, but - * the equivalent for `anyOf`/`oneOf` can have no unique consistent answer at all when two - * sibling branches each gate their own `unevaluatedProperties` on the other's claims). + * distinct exception. A real fix was deferred instead of implemented: a sound fix exists for + * `allOf` alone, but the equivalent for `anyOf`/`oneOf` can have no unique consistent answer + * at all when two sibling branches each gate their own `unevaluatedProperties` on the + * other's claims. */ public function testBranchOnlyUnevaluatedPropertiesThrowsUnsupportedSchemaFeatureException(): void { diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchWithPatternAndUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchWithPatternAndUnevaluatedSchema.json new file mode 100644 index 00000000..c469923a --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchWithPatternAndUnevaluatedSchema.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "allOf": [ + { + "properties": { + "foo": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "integer" + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchesWithoutEvaluationFields.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchesWithoutEvaluationFields.json new file mode 100644 index 00000000..9b63e713 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchesWithoutEvaluationFields.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "allOf": [ + { + "properties": { + "a": { + "type": "string" + } + } + }, + { + "patternProperties": { + "^x-": { + "type": "string" + } + } + } + ], + "unevaluatedProperties": false +} From 3dc72596c732a65ee12767cdb292fa44150fc8af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:05:21 +0000 Subject: [PATCH 47/51] Align the unevaluated-keyword docs with verified behaviour Every claim on the unevaluatedProperties/unevaluatedItems pages was re-checked against generated code and corrected where it differed: - exception message examples use the real wording (quoted names, no trailing periods); - array dead-code list: add `items: true` / `items: {}`, drop the `additionalItems: {schema}` skip and the deny-setting bullet that the generator does not implement, and state the actual behaviour (credited, no warning, validator still generated); - explicit `true` / `{}` applicators inside a composition branch credit every key or index; omitted keywords credit nothing; - compositions nested inside a branch contribute to any depth, a failed conditional contributes nothing, and untyped property subschemas and typeless roots are described; - document how the unevaluatedProperties() accessor treats keys a successful branch claims, pinned by a new test. gettingStarted.rst still named Draft 7 as the fallback and default draft; the generator falls back to Draft 2020-12. Correct that and add an upgrade warning that keywords introduced after Draft 7 are now enforced for schemas without a recognised $schema, with the two ways to keep Draft 7 behaviour. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01NocUsfMTBudWN3fmvFZg8z --- docs/source/combinedSchemas/allOf.rst | 9 +++- docs/source/combinedSchemas/anyOf.rst | 9 +++- docs/source/combinedSchemas/if.rst | 14 +++++- docs/source/combinedSchemas/oneOf.rst | 9 +++- docs/source/complexTypes/array.rst | 35 +++++++++----- docs/source/complexTypes/object.rst | 15 ++++-- ...aluatedPropertiesAccessorPostProcessor.rst | 8 ++++ docs/source/gettingStarted.rst | 31 ++++++++---- ...tedPropertiesAccessorPostProcessorTest.php | 48 +++++++++++++++++++ ...ditionalPropertiesClaimsUntypedExtras.json | 21 ++++++++ 10 files changed, 170 insertions(+), 29 deletions(-) create mode 100644 tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesClaimsUntypedExtras.json diff --git a/docs/source/combinedSchemas/allOf.rst b/docs/source/combinedSchemas/allOf.rst index c5041380..6d0a05d2 100644 --- a/docs/source/combinedSchemas/allOf.rst +++ b/docs/source/combinedSchemas/allOf.rst @@ -149,6 +149,10 @@ For an object-level composition: For a value-typed ``array`` composition, the analogous indices contributed by each branch's ``items``/``additionalItems``/``contains`` count as evaluated. +A composition nested inside a branch (an ``anyOf`` inside an ``allOf`` branch, a conditional +inside a ``oneOf`` branch, ...) contributes the claims of its own successful branches to the +enclosing branch, to any depth. + .. note:: *Omitting* ``additionalProperties`` from a branch is **not** the same as writing @@ -157,7 +161,10 @@ For a value-typed ``array`` composition, the analogous indices contributed by ea ``additionalProperties`` (whether ``true`` or ``{schema}``) contributes. Two branches with identical extras behaviour but one writing the keyword and the other omitting it will therefore credit different evaluated sets. This is a spec-mandated distinction from - JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. + JSON Schema 2019-09. The same rule applies on the array side: an explicit ``additionalItems`` + credits the indices past the tuple, and an explicit ``items: true`` or ``items: {}`` credits + every index. An explicit ``unevaluatedProperties: true`` or ``unevaluatedItems: true`` inside + a branch likewise credits every key or index, while omitting these keywords credits nothing. .. note:: diff --git a/docs/source/combinedSchemas/anyOf.rst b/docs/source/combinedSchemas/anyOf.rst index 9fcc73ff..1d6da13b 100644 --- a/docs/source/combinedSchemas/anyOf.rst +++ b/docs/source/combinedSchemas/anyOf.rst @@ -127,6 +127,10 @@ Each successful branch credits property names claimed by its ``properties``, ``p and ``additionalProperties`` (per key with a passing value), and — on the array side — the indices claimed by its ``items``/``additionalItems``/``contains``. +A composition nested inside a branch (an ``anyOf`` inside an ``allOf`` branch, a conditional +inside a ``oneOf`` branch, ...) contributes the claims of its own successful branches to the +enclosing branch, to any depth. + .. note:: *Omitting* ``additionalProperties`` from a branch is **not** the same as writing @@ -135,7 +139,10 @@ indices claimed by its ``items``/``additionalItems``/``contains``. ``additionalProperties`` (whether ``true`` or ``{schema}``) contributes. Two branches with identical extras behaviour but one writing the keyword and the other omitting it will therefore credit different evaluated sets. This is a spec-mandated distinction from - JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. + JSON Schema 2019-09. The same rule applies on the array side: an explicit ``additionalItems`` + credits the indices past the tuple, and an explicit ``items: true`` or ``items: {}`` credits + every index. An explicit ``unevaluatedProperties: true`` or ``unevaluatedItems: true`` inside + a branch likewise credits every key or index, while omitting these keywords credits nothing. .. note:: diff --git a/docs/source/combinedSchemas/if.rst b/docs/source/combinedSchemas/if.rst index cad8af18..82a564da 100644 --- a/docs/source/combinedSchemas/if.rst +++ b/docs/source/combinedSchemas/if.rst @@ -241,7 +241,14 @@ not. Each contributing branch credits property names claimed by its ``properties array side — the indices claimed by its ``items``/``additionalItems``/``contains``. Because branch outcomes depend on the input, the evaluated set is derived per validation call -and refreshed whenever the model is mutated. +and refreshed whenever the model is mutated. A conditional that fails as a whole (for example a +passing ``if`` followed by a failing ``then``) contributes nothing, not even the annotations of +the passing ``if`` — as with a failed ``allOf``, keys it would have claimed show up as +unevaluated in the error report when errors are collected. + +A composition nested inside a branch (an ``anyOf`` inside an ``allOf`` branch, a conditional +inside a ``oneOf`` branch, ...) contributes the claims of its own successful branches to the +enclosing branch, to any depth. .. note:: @@ -251,7 +258,10 @@ and refreshed whenever the model is mutated. ``additionalProperties`` (whether ``true`` or ``{schema}``) contributes. Two branches with identical extras behaviour but one writing the keyword and the other omitting it will therefore credit different evaluated sets. This is a spec-mandated distinction from - JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. + JSON Schema 2019-09. The same rule applies on the array side: an explicit ``additionalItems`` + credits the indices past the tuple, and an explicit ``items: true`` or ``items: {}`` credits + every index. An explicit ``unevaluatedProperties: true`` or ``unevaluatedItems: true`` inside + a branch likewise credits every key or index, while omitting these keywords credits nothing. .. note:: diff --git a/docs/source/combinedSchemas/oneOf.rst b/docs/source/combinedSchemas/oneOf.rst index 580b129f..b1e8ad8e 100644 --- a/docs/source/combinedSchemas/oneOf.rst +++ b/docs/source/combinedSchemas/oneOf.rst @@ -141,6 +141,10 @@ indices claimed by its ``items``/``additionalItems``/``contains``. Because the i active branch depends on the input, the evaluated set is derived per validation call and refreshed whenever the model is mutated. +A composition nested inside a branch (an ``anyOf`` inside an ``allOf`` branch, a conditional +inside a ``oneOf`` branch, ...) contributes the claims of its own successful branches to the +enclosing branch, to any depth. + .. note:: *Omitting* ``additionalProperties`` from a branch is **not** the same as writing @@ -149,7 +153,10 @@ refreshed whenever the model is mutated. ``additionalProperties`` (whether ``true`` or ``{schema}``) contributes. Two branches with identical extras behaviour but one writing the keyword and the other omitting it will therefore credit different evaluated sets. This is a spec-mandated distinction from - JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. + JSON Schema 2019-09. The same rule applies on the array side: an explicit ``additionalItems`` + credits the indices past the tuple, and an explicit ``items: true`` or ``items: {}`` credits + every index. An explicit ``unevaluatedProperties: true`` or ``unevaluatedItems: true`` inside + a branch likewise credits every key or index, while omitting these keywords credits nothing. .. note:: diff --git a/docs/source/complexTypes/array.rst b/docs/source/complexTypes/array.rst index c6b6b2dc..4591356e 100644 --- a/docs/source/complexTypes/array.rst +++ b/docs/source/complexTypes/array.rst @@ -346,7 +346,12 @@ contributes nothing. Unlike ``additionalItems``, ``unevaluatedItems`` looks across composition branches: an index covered by a branch that ended up succeeding is credited, and an index covered only by a branch -that failed is not. +that failed is not. Compositions nested inside a branch contribute their own successful branches +the same way, to any depth. + +A property subschema that declares array applicators but omits ``type: array`` is treated as an +array property: the keywords apply whenever the provided value is an array, and a value of any +other type passes through unchanged. Using ``false`` ^^^^^^^^^^^^^^^ @@ -388,7 +393,7 @@ further element raises an ``UnevaluatedItemsException`` with the offending indic .. code-block:: none - Provided JSON for example contains not allowed unevaluated items [#2, #3] + Provided JSON for 'example' contains not allowed unevaluated items [#2, #3] The thrown exception will be a *PHPModelGenerator\\Exception\\Arrays\\UnevaluatedItemsException* which provides the following methods to get further error details: @@ -435,9 +440,9 @@ violations: .. code-block:: none - Invalid unevaluated items in array example: + Invalid unevaluated items in array 'example': - invalid unevaluated item #1 - * Invalid type for item of array example. Requires int, got string + * Invalid type for 'unevaluated item': requires 'int', got 'string' The thrown exception will be a *PHPModelGenerator\\Exception\\Arrays\\InvalidUnevaluatedItemsException* which provides the following methods to get further error details: @@ -460,13 +465,18 @@ When a sibling applicator already claims every index, the ``unevaluatedItems`` v nothing to check. The generator recognises these dead-code shapes at generation time, emits a warning, and skips ``unevaluatedItems`` entirely: -- ``items: false`` (or ``items: {schema}`` in single-schema form) leaves no unclaimed index at - all, because ``items`` covers every position. +- ``items: false`` rejects every index, so the array is forced empty. +- ``items: true``, ``items: {}`` or ``items: {schema}`` in single-schema form leave no unclaimed + index at all, because ``items`` covers every position. - ``additionalItems: false`` alongside tuple ``items`` blocks any index past the tuple length — every index is either tuple-covered (and evaluated) or rejected by ``additionalItems``. -- ``additionalItems: {schema}`` alongside tuple ``items`` claims every index past the tuple - length — every index is again covered. -- An implicit ``additionalItems: false`` produced by the deny setting is treated the same way. + +Not every shape that claims all indices is recognised as dead code. ``additionalItems: true`` or +``additionalItems: {schema}`` alongside tuple ``items`` credits every index past the tuple length, +so ``unevaluatedItems`` can never fire; no warning is emitted and the validator is still +generated. Unlike ``additionalProperties`` on objects, the +`deny additional properties setting <../gettingStarted.html#deny-additional-properties>`__ has no +effect on arrays: an index past a tuple stays subject to ``unevaluatedItems``. .. note:: @@ -477,8 +487,11 @@ warning, and skips ``unevaluatedItems`` entirely: .. note:: - ``unevaluatedItems`` also accepts the boolean literal ``true``. This is a no-op — every index - is considered evaluated — and no validator is emitted. + ``unevaluatedItems`` also accepts the boolean literal ``true``. On its own this is a no-op — + every index is considered evaluated — and no validator is emitted. Inside a composition branch + an explicit ``unevaluatedItems: true`` (like ``items: true`` or ``items: {}``) annotates every + index of the array, so a successful branch credits all of them to an enclosing + ``unevaluatedItems``. An *omitted* keyword produces no annotation. .. hint:: diff --git a/docs/source/complexTypes/object.rst b/docs/source/complexTypes/object.rst index ccf7c916..675e7972 100644 --- a/docs/source/complexTypes/object.rst +++ b/docs/source/complexTypes/object.rst @@ -119,6 +119,8 @@ The native return type is ``mixed``, not ``Child | mixed``: ``mixed`` already su A ``child`` object carrying an unevaluated key is rejected, while a scalar ``child`` value is accepted without modification. A bare untyped schema (``{}``) declares no applicators, so no nested class is generated and the property remains a plain ``mixed`` value. +This applies to property subschemas. A *root* schema without ``type: object`` does not generate a class at all, so an ``unevaluatedProperties`` keyword on a typeless root has nothing to apply to — declare ``type: object`` on the root. + .. note:: The same principle applies to scalar and array applicators: a subschema declaring ``minLength`` without ``type: string``, or ``minItems`` without ``type: array``, enforces the constraint only when the value is of the matching type and accepts values of every other type. @@ -436,7 +438,7 @@ The generated model accepts ``name`` (claimed by ``properties``) and ``age`` (cl .. code-block:: none - Provided JSON for person contains not allowed unevaluated properties [extra] + Provided JSON for 'person' contains not allowed unevaluated properties ['extra'] The thrown exception will be a *PHPModelGenerator\\Exception\\Object\\UnevaluatedPropertiesException* which provides the following methods to get further error details: @@ -479,9 +481,9 @@ violations: .. code-block:: none - Provided JSON for example contains invalid unevaluated properties. + Provided JSON for 'example' contains invalid unevaluated properties - invalid unevaluated property 'note' - * Value for unevaluated property must not be longer than 10 + * Value for 'unevaluated property' must not be longer than 10 The thrown exception will be a *PHPModelGenerator\\Exception\\Object\\InvalidUnevaluatedPropertiesException* which provides the following methods to get further error details: @@ -517,8 +519,11 @@ a ``SchemaException`` at generation time. .. note:: - ``unevaluatedProperties`` also accepts the boolean literal ``true``. This is a no-op — every - property is considered evaluated — and no validator is emitted. + ``unevaluatedProperties`` also accepts the boolean literal ``true``. On its own this is a + no-op — every property is considered evaluated — and no validator is emitted. Inside a + composition branch an explicit ``unevaluatedProperties: true`` annotates every property, so a + successful branch credits all keys to an enclosing ``unevaluatedProperties``. An *omitted* + keyword produces no annotation. .. hint:: diff --git a/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst b/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst index 741c151a..3f526599 100644 --- a/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst +++ b/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst @@ -93,6 +93,14 @@ into the coverage of another sibling applicator (or vice versa) is rejected up f *RegularPropertyAsUnevaluatedPropertyException* if the key conflicts with a regularly-defined schema property. +**set** on a key that a successful composition branch claims (for example through the branch's +own ``additionalProperties``) is validated by that branch: a value violating the branch's claim is +rejected and the model is left unchanged, a conforming value is accepted and stored in the model's +raw data. Such a key is *evaluated* by the branch, so it never appears in **getAll**. The accessor +is generated under the conditions described above; it is not suppressed because of a branch-level +``additionalProperties``, since which branch succeeds can depend on the input. When a branch +always claims every extra key the bucket therefore simply stays empty. + **remove**: Removes an existing unevaluated property from the model. Returns true if the property was removed, false if it did not exist. Re-runs the enclosing schema's validation against the post-removal state — including composition re-evaluation — and throws a *ValidationException* if diff --git a/docs/source/gettingStarted.rst b/docs/source/gettingStarted.rst index 3b971d40..fc382a35 100644 --- a/docs/source/gettingStarted.rst +++ b/docs/source/gettingStarted.rst @@ -504,17 +504,32 @@ draft instance (``DraftInterface``) to pin all schemas to one draft, or a factor (``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. +the ``$schema`` keyword of each schema file to select the appropriate draft automatically. The +declared draft applies to the whole document, including every nested object, array item and +composition branch. When the keyword is absent or unrecognised, it falls back to JSON Schema +Draft 2020-12 behaviour, so schemas with different ``$schema`` declarations in the same +generation run can use different drafts. Available draft classes: -============= ================================ -Draft class Description -============= ================================ -``Draft_07`` JSON Schema Draft 7 (default) -============= ================================ +================== =============================================================== +Draft class Description +================== =============================================================== +``Draft_07`` JSON Schema Draft 7 +``Draft_2019_09`` JSON Schema Draft 2019-09 +``Draft_2020_12`` JSON Schema Draft 2020-12 (default without a recognised $schema) +================== =============================================================== + +.. warning:: + + **Upgrading: keywords introduced after Draft 7 are now enforced.** Schemas without a + recognised ``$schema`` are processed with Draft 2020-12 rules. Keywords that Draft 7 does not + define — for example ``unevaluatedProperties``, ``unevaluatedItems``, ``minContains`` and + ``maxContains`` — are enforced for these schemas wherever they appear, including nested + objects and array properties; versions that only supported Draft 7 ignored them silently. + Input that was accepted before can therefore be rejected after the upgrade. To keep the + previous behaviour for a schema declare ``"$schema": "http://json-schema.org/draft-07/schema#"`` + in it, or pin the draft for the whole run with ``setDraft(new Draft_07())``. .. seealso:: diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php index 491a1125..9ee622ae 100644 --- a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -214,6 +214,54 @@ public function testSetRejectsValueViolatingASuccessfulBranchClaim(): void $this->assertSame(['kind' => 'a'], $reconstructed->meta()->rawInput()); } + /** + * The accessor is emitted even when a successful composition branch claims every extra key + * through a non-false `additionalProperties`: the schema keyword is present and branches can + * be runtime-dependent (anyOf/oneOf/if), so the bucket is only statically empty for some + * shapes. For a branch that does claim the keys, the accessor keeps a consistent contract: + * - a value satisfying the branch's claim is accepted and stored in the raw input, but the + * key is evaluated by the branch and therefore never appears in the unevaluated bucket; + * - a value violating the claim is rejected by the composition re-validation and rolled + * back completely. + * The untyped `unevaluatedProperties: {}` keeps the accessor's value type open so both a + * conforming and a violating value can be offered. The model stays reconstructible from its + * own raw data throughout. + */ + public function testSetOnBranchClaimedKeyIsValidatedByTheBranchAndStaysOutOfTheBucket(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'BranchAdditionalPropertiesClaimsUntypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['kind' => 'a']); + + $object->unevaluatedProperties()->set('claimed', 5); + + $this->assertSame([], $object->unevaluatedProperties()->getAll()); + $this->assertSame(['kind' => 'a', 'claimed' => 5], $object->meta()->rawInput()); + + try { + $object->unevaluatedProperties()->set('violating', 'not-an-integer'); + $this->fail('Expected the branch additionalProperties claim to reject the value'); + } catch (AllOfException $exception) { + $this->assertSame( + <<getMessage(), + ); + } + + $this->assertSame([], $object->unevaluatedProperties()->getAll()); + $this->assertSame(['kind' => 'a', 'claimed' => 5], $object->meta()->rawInput()); + + $reconstructed = new $className($object->meta()->rawInput()); + $this->assertSame(['kind' => 'a', 'claimed' => 5], $reconstructed->meta()->rawInput()); + } + /** * Keys that match a declared property name must be routed through the named setter and * not the unevaluated accessor. The shim's runtime guard rejects them with a dedicated diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesClaimsUntypedExtras.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesClaimsUntypedExtras.json new file mode 100644 index 00000000..563e63d7 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesClaimsUntypedExtras.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "kind": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } + } + ], + "unevaluatedProperties": {} +} From 5cbab15d19ca7f57bd423ee8866c7ceea3009699 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:06:48 +0000 Subject: [PATCH 48/51] Keep an exact-message heredoc within the line-length limit The expected message of the dateTime-filter test sat on a 121-character source line inside a heredoc that cannot be wrapped. Moving the heredoc body to column 0 shortens the raw line without changing the asserted message. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01NocUsfMTBudWN3fmvFZg8z --- tests/Basic/UnevaluatedItemsValidatorTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 037370fd..8d351e4e 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -1157,10 +1157,10 @@ public function testTransformingFilterPersistsAndAcceptsAlreadyTransformedValues } catch (ErrorRegistryException $exception) { $this->assertSame( <<<'MSG' - Invalid unevaluated items in array 'tags': - - invalid unevaluated item #0 - * Invalid value for property 'unevaluated item' denied by filter 'dateTime': Invalid Date Time value "not-a-date" - MSG, +Invalid unevaluated items in array 'tags': + - invalid unevaluated item #0 + * Invalid value for property 'unevaluated item' denied by filter 'dateTime': Invalid Date Time value "not-a-date" +MSG, $exception->getMessage(), ); } From 08889130ab493ad12abc12617c60322f12484f4a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:31:58 +0000 Subject: [PATCH 49/51] Reject reserved PHP words as generated class names A schema whose derived class name is a PHP reserved word (a keyword such as `readonly` or `match`, or a builtin type name such as `int`, `mixed` or `self`) produced a file that cannot compile. `ReadOnly` surfaced as a ParseError from require(), and builtin type names such as `Int` ended the whole PHP process with an uncatchable fatal error. SchemaProcessor now rejects such a class name with a SchemaException that names the class and the schema file and points at the `title` keyword or a custom class name generator. The check is case-insensitive and the word list is verified against PHP's own parser; contextual words such as `enum` stay valid. The previously failing deferred test becomes a data-driven regression test with control cases. Also remove references to working notes outside the repository from three test files, rename single-letter closure variables in SchemaProcessor, and document the rule next to the class name generator. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01NocUsfMTBudWN3fmvFZg8z --- docs/source/gettingStarted.rst | 5 ++ src/SchemaProcessor/SchemaProcessor.php | 22 +++++- src/Utils/ReservedClassNames.php | 31 ++++++++ tests/Basic/BasicSchemaGenerationTest.php | 88 +++++++++++++++++------ tests/Issues/Issue/Issue168Test.php | 4 +- tests/Objects/ReferencePropertyTest.php | 8 +-- 6 files changed, 128 insertions(+), 30 deletions(-) create mode 100644 src/Utils/ReservedClassNames.php diff --git a/docs/source/gettingStarted.rst b/docs/source/gettingStarted.rst index fc382a35..c7f499aa 100644 --- a/docs/source/gettingStarted.rst +++ b/docs/source/gettingStarted.rst @@ -550,6 +550,11 @@ conventions, strip prefixes, and so on. See `Naming of nested classes `__ for a description of the built-in priority order. +A class name that is a reserved PHP word (``Class``, ``Match``, ``ReadOnly``, ``Int``, ``Mixed``, +``Self``, ... — matched case-insensitively) cannot be declared as a class. Generation throws a +``SchemaException`` naming the class and the schema file; set a different ``title`` on the schema +or use a custom class name generator to choose another name. + Custom filter ^^^^^^^^^^^^^ diff --git a/src/SchemaProcessor/SchemaProcessor.php b/src/SchemaProcessor/SchemaProcessor.php index 10dd72dc..4878f3e6 100644 --- a/src/SchemaProcessor/SchemaProcessor.php +++ b/src/SchemaProcessor/SchemaProcessor.php @@ -32,6 +32,7 @@ use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProvider\SchemaProviderInterface; use PHPModelGenerator\Utils\PropertyAttributeSynthesizer; +use PHPModelGenerator\Utils\ReservedClassNames; class SchemaProcessor { @@ -137,6 +138,21 @@ protected function generateModel( SchemaDefinitionDictionary $dictionary, bool $initialClass, ): Schema { + // A reserved word can never be declared as a class: the rendered file would not compile + // (a ParseError, or for builtin type names an uncatchable fatal error that ends the PHP + // process), so reject it here where the offending schema is still known. + if (ReservedClassNames::isReserved($className)) { + throw new SchemaException( + sprintf( + "Class name '%s' is a reserved PHP word and cannot be used for the generated class of file %s:" + . " set a different 'title' on the schema or configure a custom class name generator", + $className, + $jsonSchema->getFile(), + ), + $jsonSchema, + ); + } + $schemaSignature = $jsonSchema->getSignature(); if (!$initialClass && isset($this->processedSchema[$schemaSignature])) { @@ -856,8 +872,8 @@ private function cloneTransferredProperty( $compositionProcessor = $validator->getCompositionProcessor(); $transferredProperty = (clone $property) - ->filterValidators(static fn(Validator $v): bool => - is_a($v->getValidator(), PropertyTemplateValidator::class)) + ->filterValidators(static fn(Validator $container): bool => + is_a($container->getValidator(), PropertyTemplateValidator::class)) ->setDefaultValue(null) ->filterDecorators(static fn($decorator): bool => !($decorator instanceof DefaultArrayToEmptyArrayDecorator)); @@ -917,7 +933,7 @@ private function exclusiveBranchPropertyNeedsWidening( $branchPropertyNames = $branch->getNestedSchema() ? array_map( - static fn(PropertyInterface $p): string => $p->getName(), + static fn(PropertyInterface $branchProperty): string => $branchProperty->getName(), $branch->getNestedSchema()->getProperties(), ) : []; diff --git a/src/Utils/ReservedClassNames.php b/src/Utils/ReservedClassNames.php new file mode 100644 index 00000000..4b4fee64 --- /dev/null +++ b/src/Utils/ReservedClassNames.php @@ -0,0 +1,31 @@ + + */ + public static function reservedClassNameProvider(): array + { + return [ + 'reserved keyword readonly' => ['ReadOnly'], + 'reserved keyword class' => ['Class'], + 'reserved keyword match' => ['Match'], + 'reserved type name int' => ['Int'], + 'reserved type name mixed' => ['Mixed'], + 'reserved class name self' => ['Self'], + 'reserved constant name null' => ['Null'], + ]; + } + + /** + * A schema whose derived class name is a PHP reserved word (`readonly` is reserved since PHP + * 8.1, as are all keywords and the builtin type names) can never be written as a class + * declaration: the rendered file fails to compile. The generator must reject it at generation + * time with a SchemaException that names the offending class name and the file, instead of + * letting RenderJob::render() surface a ParseError from require(). * - * Explicit inline `"title": "ReadOnly"` plus `$originalClassNames: true` is required to - * reproduce this through the test harness: `generateClassFromFile()`'s default path always - * writes the schema to a temp file named after the *test's own* generated class name (see - * AbstractPHPModelGeneratorTestCase::generateClass()) regardless of the flag, so the - * `ReadOnly.json` fixture used elsewhere in this file never actually reaches the generator - * under the name "ReadOnly" - only an explicit `title` (which the class-name generator - * prioritizes over the filename) reliably forces the collision. + * The check is case-insensitive, as PHP resolves reserved words independent of case. * - * Unrelated to the `readOnly` JSON Schema keyword under test elsewhere in this file; this is - * purely about a schema's own name being reused, unqualified, as the derived PHP class name. - * Tracked in .claude/topics/reserved-php-classname-collision/ as a deferred, separate topic - - * out of scope for whatever change happens to be touching this file. Asserts the intended - * behavior (a SchemaException raised at generation time); currently fails because the real - * exception is a ParseError raised later, from require(). + * An explicit inline `title` plus `$originalClassNames: true` is required to reproduce this + * through the test harness: `generateClassFromFile()`'s default path always writes the schema + * to a temp file named after the *test's own* generated class name (see + * AbstractPHPModelGeneratorTestCase::generateClass()) regardless of the flag, so only an + * explicit `title` (which the class-name generator prioritizes over the filename) reliably + * forces the collision. */ - public function testClassNameCollidingWithPhpReservedWordThrowsSchemaException(): void + #[DataProvider('reservedClassNameProvider')] + public function testClassNameCollidingWithPhpReservedWordThrowsSchemaException(string $title): void { $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + '/^' . preg_quote("Class name '$title' is a reserved PHP word and cannot be used for the generated" + . " class of file ", '/') + . '\S+: set a different \'title\' on the schema or configure a custom class name generator' + . ' at line \d+, column \d+$/', + ); $this->generateClass( - '{"title": "ReadOnly", "type": "object", "properties": {"name": {"type": "string"}}}', + sprintf('{"title": "%s", "type": "object", "properties": {"name": {"type": "string"}}}', $title), null, true, ); } + /** + * Control for the reserved-word check: `Enum`, `Resource` and `Numeric` look like candidates but + * are not reserved as class names, so they must keep generating working classes. + */ + #[DataProvider('nonReservedClassNameProvider')] + public function testClassNameResemblingAReservedWordIsAccepted(string $title): void + { + $this->generateClass( + sprintf('{"title": "%s", "type": "object", "properties": {"name": {"type": "string"}}}', $title), + null, + true, + ); + + // The harness returns its own file-based name; the generated class carries the title. + $generatedClassName = $this->lastGeneratedNamespacePrefix . '\\' . $title; + $this->assertTrue(class_exists($generatedClassName), "Class $generatedClassName must have been generated"); + $this->assertSame('Alice', (new $generatedClassName(['name' => 'Alice']))->getName()); + } + + /** + * @return array + */ + public static function nonReservedClassNameProvider(): array + { + return [ + 'enum is contextual only' => ['Enum'], + 'resource is not reserved' => ['Resource'], + 'numeric is not reserved' => ['Numeric'], + ]; + } + public function testSetterChangeTheInternalState(): void { $className = $this->generateClassFromFile( diff --git a/tests/Issues/Issue/Issue168Test.php b/tests/Issues/Issue/Issue168Test.php index 398483bc..49bb084c 100644 --- a/tests/Issues/Issue/Issue168Test.php +++ b/tests/Issues/Issue/Issue168Test.php @@ -14,9 +14,7 @@ * $value` instantiation attempt) and "array" (`is_array($value)` / iterate-by-value item * validation) both key off exactly that same PHP `array` type. Fixed by additionally requiring * `array_is_list($value)` at every load-bearing guard (`TypeCheckValidator`, - * `ReflectionTypeCheckValidator`, `ObjectInstantiationDecorator`, `DefaultValueModifier`). See - * .claude/topics/array-object-type-guard-collision/analysis.md for the full investigation and - * implementation notes. + * `ReflectionTypeCheckValidator`, `ObjectInstantiationDecorator`, `DefaultValueModifier`). */ class Issue168Test extends AbstractIssueTestCase { diff --git a/tests/Objects/ReferencePropertyTest.php b/tests/Objects/ReferencePropertyTest.php index 8ce09d08..0674e281 100644 --- a/tests/Objects/ReferencePropertyTest.php +++ b/tests/Objects/ReferencePropertyTest.php @@ -1058,9 +1058,8 @@ public function testMutuallyReferencingRootCompositionsGenerateWithoutRecursingI * Deferred - a real fix is a design decision (reject the mutual reference at generation * time, which would refuse a legitimately useful "recursive polymorphic node" schema shape * unlike the tautological allOf case, vs. runtime cycle memoization), not a mechanical one. - * See `.claude/topics/self-composition-runtime-recursion/analysis.md` for the full - * analysis. Marked incomplete rather than attempting construction, to avoid burning ~512 - * stack frames through Xdebug on every suite run for a known, already-diagnosed failure. + * Marked incomplete rather than attempting construction, to avoid burning ~512 stack frames + * through Xdebug on every suite run for a known, already-diagnosed failure. */ public function testMutuallyReferencingAnyOfRootCompositionsDoesNotYetTerminate(): void { @@ -1071,7 +1070,8 @@ public function testMutuallyReferencingAnyOfRootCompositionsDoesNotYetTerminate( 'Generation succeeds, but constructing an instance recurses indefinitely - a ' . 'mutually-referencing anyOf/oneOf composition has no base case for runtime ' . 'branch-matching, unlike allOf (which flattens at generation time instead). ' - . 'Deferred; tracked in .claude/topics/self-composition-runtime-recursion/.', + . 'Deferred: a real fix needs a design decision (reject the mutual reference at ' + . 'generation time vs. runtime cycle memoization).', ); } From 9402209383eca3753581f8adf6d1a108c30e9aa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:35:17 +0000 Subject: [PATCH 50/51] Skip unevaluatedItems beside tuple items with a non-false additionalItems A tuple followed by `additionalItems: true` or `additionalItems: {schema}` evaluates every index: the tuple the head, additionalItems the tail. The unevaluatedItems keyword can therefore never fire, but unlike the `additionalItems: false` shape and the object-side `additionalProperties` equivalents it was silently kept, its validator generated and no warning logged. The factory now treats every `additionalItems` value after tuple `items` as a dead-code shape: it logs the usual warning naming the sibling keyword and skips the validator. Accepted and rejected inputs are unchanged - a test pins that the tail is decided by additionalItems alone. array.rst lists the new shapes in place of the paragraph that described them as not recognised. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01NocUsfMTBudWN3fmvFZg8z --- docs/source/complexTypes/array.rst | 9 +++-- .../UnevaluatedItemsValidatorFactory.php | 32 ++++++++++------ tests/Basic/UnevaluatedItemsValidatorTest.php | 37 ++++++++++++++++++- ...AdditionalSchemaWithUnevaluatedSchema.json | 19 ++++++++++ ...leAdditionalTrueWithUnevaluatedSchema.json | 17 +++++++++ 5 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalSchemaWithUnevaluatedSchema.json create mode 100644 tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalTrueWithUnevaluatedSchema.json diff --git a/docs/source/complexTypes/array.rst b/docs/source/complexTypes/array.rst index 4591356e..0d21749e 100644 --- a/docs/source/complexTypes/array.rst +++ b/docs/source/complexTypes/array.rst @@ -470,11 +470,12 @@ warning, and skips ``unevaluatedItems`` entirely: index at all, because ``items`` covers every position. - ``additionalItems: false`` alongside tuple ``items`` blocks any index past the tuple length — every index is either tuple-covered (and evaluated) or rejected by ``additionalItems``. +- ``additionalItems: true`` or ``additionalItems: {schema}`` alongside tuple ``items`` accepts + and evaluates every index past the tuple length (a schema additionally validates it) — every + index is again claimed. A tail value is accepted or rejected by ``additionalItems`` alone; a + skipped ``unevaluatedItems`` schema never takes part in the decision. -Not every shape that claims all indices is recognised as dead code. ``additionalItems: true`` or -``additionalItems: {schema}`` alongside tuple ``items`` credits every index past the tuple length, -so ``unevaluatedItems`` can never fire; no warning is emitted and the validator is still -generated. Unlike ``additionalProperties`` on objects, the +Unlike ``additionalProperties`` on objects, the `deny additional properties setting <../gettingStarted.html#deny-additional-properties>`__ has no effect on arrays: an index past a tuple stays subject to ``unevaluatedItems``. diff --git a/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php index 428f40e0..5c35120e 100644 --- a/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php +++ b/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php @@ -77,9 +77,10 @@ public function modify( } /** - * Three sibling-shape combinations make the unevaluatedItems keyword unreachable: the + * Several sibling-shape combinations make the unevaluatedItems keyword unreachable: the * array is forced empty by `items: false`, every index is already claimed by an - * `items: {schema}`, or the tuple length is fully covered with `additionalItems: false`. + * `items: true` / `items: {schema}`, or tuple `items` is followed by an `additionalItems` + * of any value (`false` rejects the tail, `true` / `{schema}` claim it). * Each one is spec-legal and emits a generation-time warning instead of a SchemaException * — the developer's intent is intact but the keyword cannot contribute. */ @@ -120,18 +121,25 @@ private function isDeadCode( $isTupleItems = is_array($items) && $items !== [] && array_is_list($items); if ($isTupleItems) { - if (($json['additionalItems'] ?? null) === false) { - $this->warn( - $schemaProcessor, - $schema, - $property, - "sibling additionalItems: false rejects every tail index past the tuple", - ); - - return true; + if (!array_key_exists('additionalItems', $json)) { + return false; } - return false; + // The tuple evaluates the head; additionalItems then covers every index past it. With + // `false` the tail is rejected outright, with `true` or a schema it is accepted and + // evaluated (and, for a schema, validated). Either way no index is left over. + $additionalItems = $json['additionalItems']; + + $reason = match (true) { + $additionalItems === false => 'rejects every tail index past the tuple', + $additionalItems === true => 'already claims every tail index past the tuple', + default => 'already validates every tail index past the tuple', + }; + $keywordValue = is_bool($additionalItems) ? var_export($additionalItems, true) : '{schema}'; + + $this->warn($schemaProcessor, $schema, $property, "sibling additionalItems: $keywordValue $reason"); + + return true; } // Schema-form items claims every index per the spec; nothing is left over for the diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php index 8d351e4e..43b62478 100644 --- a/tests/Basic/UnevaluatedItemsValidatorTest.php +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -5,9 +5,10 @@ namespace PHPModelGenerator\Tests\Basic; use DateTime; +use PHPModelGenerator\Exception\Arrays\InvalidAdditionalTupleItemsException; use PHPModelGenerator\Exception\Arrays\InvalidUnevaluatedItemsException; -use PHPModelGenerator\Exception\Arrays\UniqueItemsException; use PHPModelGenerator\Exception\Arrays\UnevaluatedItemsException; +use PHPModelGenerator\Exception\Arrays\UniqueItemsException; use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\ComposedValue\AllOfException; use PHPModelGenerator\Exception\Generic\InvalidTypeException; @@ -391,7 +392,7 @@ public function testUniqueItemsExceptionIdentityIsPreserved(): void } /** - * Three sibling shapes make `unevaluatedItems` unreachable; each emits a generation-time + * Several sibling shapes make `unevaluatedItems` unreachable; each emits a generation-time * warning instead of a SchemaException. The warning pin captures the developer's intended * class/property identifier so the source of the dead code is obvious in build output. * @@ -423,6 +424,17 @@ public static function deadCodeProvider(): array 'TupleAdditionalFalseWithUnevaluatedFalse.json', "sibling additionalItems: false rejects every tail index past the tuple", ], + // Tuple items + additionalItems: true accepts and evaluates every tail index; with the + // tuple covering the head, every index is claimed. + 'tuple items with additionalItems: true' => [ + 'TupleAdditionalTrueWithUnevaluatedSchema.json', + "sibling additionalItems: true already claims every tail index past the tuple", + ], + // Tuple items + additionalItems: {schema} validates and evaluates every tail index. + 'tuple items with additionalItems: {schema}' => [ + 'TupleAdditionalSchemaWithUnevaluatedSchema.json', + "sibling additionalItems: {schema} already validates every tail index past the tuple", + ], ]; } @@ -452,6 +464,27 @@ public function testDeadCodeShapesWarn(string $schemaFile, string $reason): void $this->assertSame(['tags' => []], $instance->meta()->rawInput()); } + /** + * With `additionalItems: {schema}` beside a tuple, the tail is validated (and evaluated) by + * `additionalItems` alone. The skipped `unevaluatedItems` schema must not influence the + * outcome: a tail value is accepted or rejected exactly as `additionalItems` decides, never by + * the unevaluated schema (here a boolean, which no integer tail value could satisfy). + */ + public function testSkippedUnevaluatedItemsDoesNotInfluenceTheAdditionalItemsOutcome(): void + { + $className = $this->generateClassFromFile('TupleAdditionalSchemaWithUnevaluatedSchema.json'); + + $accepted = new $className(['tags' => ['head', 1, 2]]); + $this->assertSame(['head', 1, 2], $accepted->getTags()); + + try { + new $className(['tags' => ['head', true]]); + $this->fail('Expected additionalItems to reject the non-integer tail value'); + } catch (InvalidAdditionalTupleItemsException $exception) { + $this->assertSame('tags', $exception->getPropertyName()); + } + } + /** * Values that are neither a boolean nor a schema object must fail loudly at generation time * with a SchemaException naming the offending value and the property or class carrying the keyword; diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalSchemaWithUnevaluatedSchema.json b/tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalSchemaWithUnevaluatedSchema.json new file mode 100644 index 00000000..2756942c --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalSchemaWithUnevaluatedSchema.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "type": "integer" + }, + "unevaluatedItems": { + "type": "boolean" + } + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalTrueWithUnevaluatedSchema.json b/tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalTrueWithUnevaluatedSchema.json new file mode 100644 index 00000000..df918fc8 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalTrueWithUnevaluatedSchema.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": [ + { + "type": "string" + } + ], + "additionalItems": true, + "unevaluatedItems": { + "type": "boolean" + } + } + } +} From 2d74f2977644d8a8ad390d791f6b7037b2cb5079 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:41:49 +0000 Subject: [PATCH 51/51] Run the reserved-word control test in a single draft The control test generates classes with fixed names taken from the schema title (`Enum`, `Resource`, `Numeric`). With full draft coverage every test runs once per draft in the same PHP process, so the second draft run redeclared the class and aborted the whole run with a fatal error. The check does not depend on the draft, so restrict the test to one. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01NocUsfMTBudWN3fmvFZg8z --- tests/Basic/BasicSchemaGenerationTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Basic/BasicSchemaGenerationTest.php b/tests/Basic/BasicSchemaGenerationTest.php index 497a5702..b58c18eb 100644 --- a/tests/Basic/BasicSchemaGenerationTest.php +++ b/tests/Basic/BasicSchemaGenerationTest.php @@ -18,6 +18,7 @@ use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use PHPModelGenerator\Tests\Support\ApplicableDrafts; +use PHPModelGenerator\Tests\Support\JsonSchemaDraft; use PHPUnit\Framework\Attributes\DataProvider; #[ApplicableDrafts] @@ -188,7 +189,12 @@ public function testClassNameCollidingWithPhpReservedWordThrowsSchemaException(s /** * Control for the reserved-word check: `Enum`, `Resource` and `Numeric` look like candidates but * are not reserved as class names, so they must keep generating working classes. + * + * Restricted to one draft: the generated class carries the fixed name from the title and every + * generation loads it into the same PHP process, so a second draft run would redeclare it. The + * reserved-word check is independent of the draft. */ + #[ApplicableDrafts(from: JsonSchemaDraft::DRAFT_2020_12)] #[DataProvider('nonReservedClassNameProvider')] public function testClassNameResemblingAReservedWordIsAccepted(string $title): void {