diff --git a/CLAUDE.md b/CLAUDE.md index 75a7465b..27fd336a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -215,19 +215,19 @@ Markdown files. Rules: - Create the directory and at least a stub `implementation-plan.md` (or `analysis.md`) before - writing any code, so the plan is committed alongside the first code change. + writing any code. The plan is working context for the current session, not a git artefact. - Every implementation plan must include a dedicated documentation update step. Before finalising the plan, audit `docs/source/` (RST), `README.md`, and any other user-facing docs for content that would be affected by the change, and add a plan phase that updates those docs. Do not skip this even if the doc changes appear minor. -- Commit the plan files together with related code changes so the reasoning is always traceable in - git history. +- **Never add planning documents to git — not even on feature branches.** Files under + `.claude/issues/` and `.claude/topics/` are working notes for Claude's use only. Never stage or + commit them. If they appear in `git status`, run `git restore --staged ` immediately. - Update the plan file(s) as the work progresses — record decisions made, phases completed, and any pivots in approach. -- Once a topic is **ready to merge**, delete the entire `.claude/issues//` or - `.claude/topics//` directory and commit that deletion as the final commit on the branch, - **before** merging to `master`. The tracking files are working notes and must never land on - `master`. +- Once a topic is **complete**, delete the entire `.claude/issues//` or + `.claude/topics//` directory. These files must never land on `master` — delete before + merging. Example layout for issue #110: @@ -291,6 +291,17 @@ Every identified edge case must have a corresponding test. During planning, enum cases explicitly (in the implementation plan). Before marking work done, verify that each enumerated edge case is covered by at least one test. +#### Exception message assertions + +Always assert the **complete** exception message, not just a substring. Construct the expected +message in full using the same inputs the code under test uses. Use regex (via +`expectExceptionMessageMatches` or `assertMatchesRegularExpression`) only for genuinely dynamic +parts that cannot be predicted upfront (e.g. file paths, uniqid suffixes). + +Never use multiple `assertStringContainsString` calls on the same exception message when the full +message can be constructed. A single `assertSame($expectedMessage, $exception->getMessage())` is +both stronger and self-documenting. + For pull requests, check the qlty.sh coverage report by constructing the URL from the current PR number: diff --git a/docs/source/combinedSchemas/allOf.rst b/docs/source/combinedSchemas/allOf.rst index e3e8e1a6..7783dac8 100644 --- a/docs/source/combinedSchemas/allOf.rst +++ b/docs/source/combinedSchemas/allOf.rst @@ -72,6 +72,16 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\All When combining multiple nested objects with an `allOf` composition a `merged property `__ will be generated +.. note:: + + ``allOf`` branches can be the boolean literals ``true`` or ``false``. + + - ``true`` branch — treated as an empty schema; any value satisfies it and it adds no constraint. + - ``false`` branch — makes the whole composition unsatisfiable; any provided value raises an + ``AllOfException`` at runtime (the false branch is represented as an always-failing composition + element). The generator also emits a warning at generation time. Absent optional properties + are still allowed. + .. note:: When a property is defined in multiple ``allOf`` branches with conflicting types (e.g. one branch diff --git a/docs/source/combinedSchemas/anyOf.rst b/docs/source/combinedSchemas/anyOf.rst index 173f613c..7d48d08b 100644 --- a/docs/source/combinedSchemas/anyOf.rst +++ b/docs/source/combinedSchemas/anyOf.rst @@ -58,6 +58,16 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\Any // get the value provided to the property public function getProvidedValue() +.. note:: + + ``anyOf`` branches can be the boolean literals ``true`` or ``false``. + + - ``true`` branch — always satisfies the branch; treated as an empty schema. + - ``false`` branch — can never be satisfied; always-failing branches participate in the + composition but never succeed. If all branches are ``false``, any provided value raises an + ``AnyOfException`` at runtime, and the generator emits a warning at generation time. + Absent optional properties are still allowed. + .. hint:: When combining multiple nested objects with an `anyOf` composition a `merged property `__ will be generated diff --git a/docs/source/combinedSchemas/if.rst b/docs/source/combinedSchemas/if.rst index e9f309c7..b8484561 100644 --- a/docs/source/combinedSchemas/if.rst +++ b/docs/source/combinedSchemas/if.rst @@ -162,6 +162,24 @@ When only a ``then`` block is present (no ``else``), the branch may not apply at public function setAge(?int $age): static; public function getAge(): ?int; +.. note:: + + Any of the three branches (``if``, ``then``, ``else``) can be the boolean literal ``true`` or + ``false``. The generator resolves these statically at generation time: + + - ``if: false`` — condition never matches; ``else`` (if present) is applied unconditionally, + ``then`` is ignored. + - ``if: true`` — condition always matches; ``then`` (if present) is applied unconditionally, + ``else`` is ignored. + - ``if: false, else: false`` or ``if: true, then: false`` — the composition is always + unsatisfiable; providing any value raises a ``ConditionalException`` at runtime. The generator + also emits a warning at generation time. + - ``then: false`` / ``else: false`` (with a real schema for ``if``) — when the relevant + branch is entered, the value would always be invalid; the generator throws a + ``SchemaException`` at generation time. + - ``then: true`` / ``else: true`` — when the relevant branch is entered, any value is + accepted; treated as absent (no additional constraint). + .. hint:: The union-widening and nullability rules for ``if``/``then``/``else`` follow the same logic as diff --git a/docs/source/combinedSchemas/not.rst b/docs/source/combinedSchemas/not.rst index 0b1d4a07..1b5e6b46 100644 --- a/docs/source/combinedSchemas/not.rst +++ b/docs/source/combinedSchemas/not.rst @@ -45,3 +45,11 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\Not public function getPropertyName(): string // get the value provided to the property public function getProvidedValue() + +.. note:: + + The ``not`` schema can be the boolean literal ``true`` or ``false``. + + - ``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. diff --git a/docs/source/combinedSchemas/oneOf.rst b/docs/source/combinedSchemas/oneOf.rst index e14a3a4d..74ad7c94 100644 --- a/docs/source/combinedSchemas/oneOf.rst +++ b/docs/source/combinedSchemas/oneOf.rst @@ -68,6 +68,16 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\One // get the value provided to the property public function getProvidedValue() +.. note:: + + ``oneOf`` branches can be the boolean literals ``true`` or ``false``. + + - ``true`` branch — treated as an empty schema; always satisfies the branch. + - ``false`` branch — can never be satisfied; always-failing branches participate in the + composition but never succeed. If all branches are ``false``, any provided value raises a + ``OneOfException`` at runtime, and the generator emits a warning at generation time. + Absent optional properties are still allowed. + .. hint:: When combining multiple nested objects with an `oneOf` composition a `merged property `__ will be generated diff --git a/docs/source/complexTypes/array.rst b/docs/source/complexTypes/array.rst index 43857607..a2ca072c 100644 --- a/docs/source/complexTypes/array.rst +++ b/docs/source/complexTypes/array.rst @@ -143,6 +143,13 @@ The *getMembers* function of the class *Family* is type hinted with *@returns Me Arrays with item validation don't accept elements which contain `null`. If your array needs to accept `null` entries you have to add null to the type of your items explicitly (eg. "type": ["object", "null"]). +The ``items`` keyword also accepts the boolean literals ``true`` and ``false``. + +``items: true`` — any array element is accepted; equivalent to not specifying ``items``. + +``items: false`` — the array must be empty. Providing a non-empty array throws a ``MaxItemsException`` +(Array X must not contain more than 0 items). + Tuples ------ @@ -307,6 +314,14 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\Arrays\\ContainsEx // get the value provided to the property public function getProvidedValue() +The ``contains`` keyword also accepts the boolean literals ``true`` and ``false``. + +``contains: true`` — the array must contain at least one element (since ``true`` validates everything, +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. + Size validation --------------- diff --git a/docs/source/complexTypes/object.rst b/docs/source/complexTypes/object.rst index 8bfc71cd..a9f18b85 100644 --- a/docs/source/complexTypes/object.rst +++ b/docs/source/complexTypes/object.rst @@ -165,7 +165,35 @@ Possible exceptions: Properties defined in the `required` array but not defined in the `properties` will be added to the interface of the generated class. - A schema defining only the required property `example` consequently will provide the methods `getExample(): mixed` and `setExample(mixed $value): static`. +Boolean property schemas +^^^^^^^^^^^^^^^^^^^^^^^^ + +A property schema can be the boolean literal ``true`` or ``false`` instead of a JSON object schema. + +``true`` — the property accepts any value without restriction. A getter is generated with return type ``mixed``. + +``false`` — the property is explicitly forbidden. Providing it throws a ``DeniedPropertyException``. Listing a forbidden property in ``required`` is a schema error detected at generation time. + +.. code-block:: json + + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "anything": true, + "forbidden": false + } + } + +Generated interface: + +.. code-block:: php + + public function getName(): ?string; + public function getAnything(): mixed; + // No getter is generated for 'forbidden'; providing it throws DeniedPropertyException Size ---- @@ -580,3 +608,20 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\Object\\InvalidPat This also applies to properties transferred from composition branches (``anyOf``, ``oneOf``, ``allOf``, ``if``/``then``/``else``). + +A pattern schema can also be the boolean literal ``true`` or ``false``. + +``true`` — properties matching the pattern are accepted without restriction (useful when combining +with ``additionalProperties: false`` to be explicit about intent). + +``false`` — properties matching the pattern are forbidden. Providing any such property throws a +``DeniedPropertyException``. + +.. code-block:: json + + { + "type": "object", + "patternProperties": { + "^internal_.*": false + } + } diff --git a/src/Model/Property/CompositionPropertyDecorator.php b/src/Model/Property/CompositionPropertyDecorator.php index 0927c65b..a87f1f4b 100644 --- a/src/Model/Property/CompositionPropertyDecorator.php +++ b/src/Model/Property/CompositionPropertyDecorator.php @@ -25,6 +25,8 @@ class CompositionPropertyDecorator extends PropertyProxy */ protected $affectedObjectProperties = []; + private bool $alwaysTrueBranch = false; + /** * CompositionPropertyDecorator constructor. * @@ -60,6 +62,16 @@ public function getAffectedObjectProperties(): array return $this->affectedObjectProperties; } + public function markAsAlwaysTrueBranch(): void + { + $this->alwaysTrueBranch = true; + } + + public function isAlwaysTrueBranch(): bool + { + return $this->alwaysTrueBranch; + } + /** * Return the branch-level JSON schema (the composition element schema, which may contain * additionalProperties constraints). This is distinct from getJsonSchema(), which proxies diff --git a/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php index c0f99092..683b036b 100644 --- a/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php +++ b/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php @@ -5,21 +5,18 @@ namespace PHPModelGenerator\Model\Validator\Factory\Arrays; use PHPModelGenerator\Exception\Arrays\ContainsException; -use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; 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 { - /** - * @throws SchemaException - */ public function modify( SchemaProcessor $schemaProcessor, Schema $schema, @@ -32,6 +29,30 @@ public function modify( return; } + if (is_bool($json[$this->key])) { + if ($json[$this->key] === false) { + if ($schemaProcessor->getGeneratorConfiguration()->isOutputEnabled()) { + // @codeCoverageIgnoreStart + echo "Warning: contains: false for property '{$property->getName()}'" + . " can never be satisfied; any array will fail\n"; + // @codeCoverageIgnoreEnd + } + + $property->addValidator( + new PropertyValidator( + $property, + 'is_array($value)', + ContainsException::class, + ) + ); + return; + } + + $propertySchema = $propertySchema->withJson( + array_merge($json, [$this->key => []]), + ); + } + $nestedProperty = (new PropertyFactory()) ->create( $schemaProcessor, diff --git a/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php index 2eb1dfac..bfd72e42 100644 --- a/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php +++ b/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php @@ -5,6 +5,7 @@ namespace PHPModelGenerator\Model\Validator\Factory\Arrays; use PHPModelGenerator\Exception\Arrays\AdditionalTupleItemsException; +use PHPModelGenerator\Exception\Arrays\MaxItemsException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; @@ -35,6 +36,21 @@ public function modify( $itemsSchema = $json[$this->key]; + if (is_bool($itemsSchema)) { + if ($itemsSchema === false) { + $property->addValidator( + new PropertyValidator( + $property, + 'count($value) > 0', + MaxItemsException::class, + [0], + ), + ); + } + + return; + } + // tuple validation: items is a sequential array if ( is_array($itemsSchema) && diff --git a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php index 98535951..2437cd61 100644 --- a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php @@ -4,7 +4,7 @@ namespace PHPModelGenerator\Model\Validator\Factory\Composition; -use PHPModelGenerator\Exception\SchemaException; +use PHPModelGenerator\Exception\Generic\DeniedPropertyException; use PHPModelGenerator\Model\Property\BaseProperty; use PHPModelGenerator\Model\Property\CompositionPropertyDecorator; use PHPModelGenerator\Model\Property\PropertyInterface; @@ -14,6 +14,7 @@ use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Validator\ComposedPropertyValidator; use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory; +use PHPModelGenerator\Model\Validator\PropertyValidator; use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ClearTypeHintDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator; @@ -22,6 +23,21 @@ abstract class AbstractCompositionValidatorFactory extends AbstractValidatorFactory { + /** + * Emit a generation-time warning for always-unsatisfiable composition schemas. + */ + protected function warnIfAlwaysFalse( + SchemaProcessor $schemaProcessor, + PropertyInterface $property, + string $reason, + ): void { + if ($schemaProcessor->getGeneratorConfiguration()->isOutputEnabled()) { + // @codeCoverageIgnoreStart + echo "Warning: always-unsatisfiable schema for property '{$property->getName()}': $reason\n"; + // @codeCoverageIgnoreEnd + } + } + /** * Emit a warning when the composition array for the current keyword is empty. */ @@ -77,6 +93,26 @@ protected function getCompositionProperties( $property->addTypeHintDecorator(new ClearTypeHintDecorator()); foreach ($json[$this->key] as $index => $compositionElement) { + if ($compositionElement === false) { + $compositionProperties[] = $this->createAlwaysFalseBranchProperty( + $schemaProcessor, + $schema, + $property, + $propertySchema->getJson()['propertySchema'], + ); + continue; + } + + if ($compositionElement === true) { + $compositionProperties[] = $this->createAlwaysTrueBranchProperty( + $schemaProcessor, + $schema, + $property, + $propertySchema->getJson()['propertySchema'], + ); + continue; + } + $compositionSchema = $propertySchema->getJson()['propertySchema']->navigate("$this->key/$index"); $compositionProperty = new CompositionPropertyDecorator( @@ -109,6 +145,97 @@ protected function getCompositionProperties( return $compositionProperties; } + /** + * Create a composition branch for a boolean `false` schema element. + * + * The branch always fails when the property key is present in $modelData, so absent optional + * properties are not denied. Used for false branches in allOf/anyOf/oneOf compositions. + */ + protected function createAlwaysFalseBranchProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $parentSchema, + ): CompositionPropertyDecorator { + $propertyFactory = new PropertyFactory(); + $branchSchema = $parentSchema->withJson([]); + + $branchProperty = new CompositionPropertyDecorator( + $property->getName(), + $branchSchema, + $propertyFactory->create( + $schemaProcessor, + $schema, + $property->getName(), + $branchSchema, + $property->isRequired(), + ), + ); + + $presenceCheck = "array_key_exists('" . addslashes($property->getName()) . "', \$modelData)"; + + $branchProperty->onResolve( + function () use ($branchProperty, $presenceCheck): void { + $branchProperty->filterValidators( + static fn(Validator $validator): bool => + !is_a($validator->getValidator(), RequiredPropertyValidator::class) && + !is_a($validator->getValidator(), ComposedPropertyValidator::class), + ); + $branchProperty->addValidator( + new PropertyValidator( + $branchProperty, + $presenceCheck, + DeniedPropertyException::class, + ), + ); + }, + ); + + return $branchProperty; + } + + /** + * Create a composition branch for a boolean `true` schema element. + * + * The branch always succeeds (no validators) and is marked as an always-true branch so that + * type inference excludes it from type narrowing. Used for true branches in allOf/anyOf/oneOf. + */ + protected function createAlwaysTrueBranchProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $parentSchema, + ): CompositionPropertyDecorator { + $propertyFactory = new PropertyFactory(); + $branchSchema = $parentSchema->withJson([]); + + $branchProperty = new CompositionPropertyDecorator( + $property->getName(), + $branchSchema, + $propertyFactory->create( + $schemaProcessor, + $schema, + $property->getName(), + $branchSchema, + $property->isRequired(), + ), + ); + + $branchProperty->markAsAlwaysTrueBranch(); + + $branchProperty->onResolve(function () use ($branchProperty): void { + $branchProperty->filterValidators( + static fn(Validator $validator): bool => + !is_a($validator->getValidator(), RequiredPropertyValidator::class) && + !is_a($validator->getValidator(), ComposedPropertyValidator::class), + ); + // No validator added — true schema always succeeds. + // No type hint decorator — true schema contributes no type constraint. + }); + + return $branchProperty; + } + /** * Inherit a parent-level type into composition branches that declare no type. */ @@ -130,7 +257,7 @@ protected function inheritPropertyType(JsonSchema $propertySchema): JsonSchema return $this->inheritIfPropertyType($propertySchema->withJson($json)); default: foreach ($json[$this->key] as &$composedElement) { - if (!isset($composedElement['type'])) { + if (!is_bool($composedElement) && !isset($composedElement['type'])) { $composedElement['type'] = $json['type']; } } @@ -147,7 +274,7 @@ protected function inheritIfPropertyType(JsonSchema $propertySchema): JsonSchema $json = $propertySchema->getJson(); foreach (['if', 'then', 'else'] as $keyword) { - if (!isset($json[$keyword])) { + if (!isset($json[$keyword]) || is_bool($json[$keyword])) { continue; } @@ -177,26 +304,39 @@ protected function transferPropertyType( } } + // For anyOf/oneOf: a true branch always satisfies the composition for any value, + // so the property type cannot be narrowed — leave it untyped. + // For allOf: exclude true branches from type computation; they contribute no constraint. + $activeBranches = array_values(array_filter( + $compositionProperties, + static fn(CompositionPropertyDecorator $compositionProperty): bool => + !$compositionProperty->isAlwaysTrueBranch(), + )); + + if (!$isAllOf && count($activeBranches) < count($compositionProperties)) { + return; + } + $allNames = array_merge(...array_map( static fn(CompositionPropertyDecorator $p): array => $p->getType() ? $p->getType()->getNames() : [], - $compositionProperties, + $activeBranches, )); $hasBranchWithNoType = array_filter( - $compositionProperties, + $activeBranches, static fn(CompositionPropertyDecorator $p): bool => $p->getType() === null, ) !== []; $hasBranchWithRequiredProperty = array_filter( - $compositionProperties, + $activeBranches, static fn(CompositionPropertyDecorator $p): bool => $p->isRequired(), ) !== []; $hasBranchWithOptionalProperty = $isAllOf ? !$hasBranchWithRequiredProperty : array_filter( - $compositionProperties, + $activeBranches, static fn(CompositionPropertyDecorator $p): bool => !$p->isRequired(), ) !== []; diff --git a/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php index 9b76ec2e..d48368cb 100644 --- a/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php @@ -5,7 +5,6 @@ namespace PHPModelGenerator\Model\Validator\Factory\Composition; use PHPModelGenerator\Exception\ComposedValue\AllOfException; -use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Property\BaseProperty; use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; @@ -18,9 +17,6 @@ class AllOfValidatorFactory extends AbstractCompositionValidatorFactory implements ComposedPropertiesValidatorFactoryInterface { - /** - * @throws SchemaException - */ public function modify( SchemaProcessor $schemaProcessor, Schema $schema, @@ -31,6 +27,14 @@ public function modify( return; } + if (in_array(false, $propertySchema->getJson()[$this->key], true)) { + $this->warnIfAlwaysFalse( + $schemaProcessor, + $property, + 'allOf contains a false branch which can never be satisfied', + ); + } + $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); $propertySchema = $this->inheritPropertyType($propertySchema); diff --git a/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php index 33c25085..29798be6 100644 --- a/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php @@ -5,7 +5,6 @@ namespace PHPModelGenerator\Model\Validator\Factory\Composition; use PHPModelGenerator\Exception\ComposedValue\AnyOfException; -use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Property\BaseProperty; use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; @@ -18,9 +17,6 @@ class AnyOfValidatorFactory extends AbstractCompositionValidatorFactory implements ComposedPropertiesValidatorFactoryInterface { - /** - * @throws SchemaException - */ public function modify( SchemaProcessor $schemaProcessor, Schema $schema, @@ -31,6 +27,15 @@ public function modify( return; } + $branches = $propertySchema->getJson()[$this->key]; + if (!empty($branches) && array_filter($branches, static fn($branch) => $branch !== false) === []) { + $this->warnIfAlwaysFalse( + $schemaProcessor, + $property, + 'all anyOf branches are false; no value can satisfy the schema', + ); + } + $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); $propertySchema = $this->inheritPropertyType($propertySchema); diff --git a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php index 64d50c4c..ca3fae23 100644 --- a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php @@ -4,6 +4,7 @@ namespace PHPModelGenerator\Model\Validator\Factory\Composition; +use PHPModelGenerator\Exception\Generic\DeniedPropertyException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Property\BaseProperty; use PHPModelGenerator\Model\Property\CompositionPropertyDecorator; @@ -13,6 +14,7 @@ use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Validator\ComposedPropertyValidator; use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator; +use PHPModelGenerator\Model\Validator\PropertyValidator; use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -47,7 +49,13 @@ public function modify( ); } - $propertySchema = $this->inheritPropertyType($propertySchema); + $json = $this->resolveBooleanBranches($json, $property, $schemaProcessor); + + if ($json === null) { + return; + } + + $propertySchema = $this->inheritPropertyType($propertySchema->withJson($json)); $json = $propertySchema->getJson(); $propertyFactory = new PropertyFactory(); @@ -65,6 +73,16 @@ public function modify( continue; } + if ($json[$keyword] === false) { + $properties[$keyword] = $this->createAlwaysFailingBranchProperty( + $schemaProcessor, + $schema, + $property, + $propertySchema, + ); + continue; + } + $compositionSchema = $propertySchema->navigate($keyword); $compositionProperty = new CompositionPropertyDecorator( @@ -109,4 +127,172 @@ public function modify( 100, ); } + + /** + * Create a composition branch that always fails, used for boolean `false` if/then/else branches. + * + * Unlike the allOf/anyOf/oneOf false-branch (which uses array_key_exists to guard absent + * optional properties), here the outer ConditionalComposedItem template's onlyForDefinedValues + * guard already prevents the entire conditional from running for absent properties. So the + * branch itself just needs to always throw regardless of the value. + */ + private function createAlwaysFailingBranchProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): CompositionPropertyDecorator { + $propertyFactory = new PropertyFactory(); + $branchSchema = $propertySchema->withJson([]); + + $branchProperty = new CompositionPropertyDecorator( + $property->getName(), + $branchSchema, + $propertyFactory->create( + $schemaProcessor, + $schema, + $property->getName(), + $branchSchema, + $property->isRequired(), + ), + ); + + $branchProperty->onResolve(function () use ($branchProperty): void { + $branchProperty->filterValidators( + static fn(Validator $validator): bool => + !is_a($validator->getValidator(), RequiredPropertyValidator::class) && + !is_a($validator->getValidator(), ComposedPropertyValidator::class), + ); + $branchProperty->addValidator( + new PropertyValidator( + $branchProperty, + 'true', + DeniedPropertyException::class, + ), + ); + }); + + return $branchProperty; + } + + /** + * Resolve boolean `if`/`then`/`else` branches into concrete schema arrays or return-null signals. + * + * Returns null when the entire if/then/else imposes no constraint and modify() should return + * early. Returns the (possibly rewritten) $json array otherwise. Always-false branches are left + * as boolean false values in the returned array so the foreach loop in modify() can handle them. + * + * @throws SchemaException + */ + private function resolveBooleanBranches( + array $json, + PropertyInterface $property, + SchemaProcessor $schemaProcessor, + ): ?array { + if (is_bool($json['if'])) { + if ($json['if'] === false) { + if (!isset($json['else'])) { + if (isset($json['then']) && $schemaProcessor->getGeneratorConfiguration()->isOutputEnabled()) { + // @codeCoverageIgnoreStart + echo "Warning: if: false for property '{$property->getName()}'" + . " — then branch will never apply (condition never matches); no constraint generated.\n"; + // @codeCoverageIgnoreEnd + } + return null; + } + + if ($json['else'] === true) { + return null; + } + + if ($json['else'] === false) { + $this->warnIfAlwaysFalse( + $schemaProcessor, + $property, + 'if: false with else: false means the composition is always unsatisfiable', + ); + // Rewrite as if: {} (always passes), then: false (always fails). + // The false then-branch is handled in the foreach loop below. + $json['if'] = []; + $json['then'] = false; + unset($json['else']); + return $json; + } + + // Rewrite if: false, else: X as if: {}, then: X. + // An empty if schema always passes so then always applies. + // The ConditionalException will say "Condition: Valid" which is accurate + // for if: {} but won't mention "else"; the message still correctly names + // the failing branch constraint. + $json['if'] = []; + $json['then'] = $json['else']; + unset($json['else']); + + return $json; + } + + if (!isset($json['then'])) { + if (isset($json['else']) && $schemaProcessor->getGeneratorConfiguration()->isOutputEnabled()) { + // @codeCoverageIgnoreStart + echo "Warning: if: true for property '{$property->getName()}'" + . " — else branch will never apply (condition always matches); no constraint generated.\n"; + // @codeCoverageIgnoreEnd + } + return null; + } + + if ($json['then'] === true) { + return null; + } + + if ($json['then'] === false) { + $this->warnIfAlwaysFalse( + $schemaProcessor, + $property, + 'if: true with then: false means the composition is always unsatisfiable', + ); + } + + // Rewrite if: true, then: Y as if: {}, then: Y (removing else — it never applies). + // If then is false the false-branch is handled in the foreach loop below. + $json['if'] = []; + unset($json['else']); + + return $json; + } + + if (isset($json['then']) && is_bool($json['then'])) { + if ($json['then'] === false) { + throw new SchemaException( + sprintf( + 'then: false is unsatisfiable for property %s in file %s', + $property->getName(), + $property->getJsonSchema()->getFile(), + ), + ); + } + + unset($json['then']); + } + + if (isset($json['else']) && is_bool($json['else'])) { + if ($json['else'] === false) { + throw new SchemaException( + sprintf( + 'else: false is unsatisfiable for property %s in file %s', + $property->getName(), + $property->getJsonSchema()->getFile(), + ), + ); + } + + unset($json['else']); + } + + if (!isset($json['then']) && !isset($json['else'])) { + return null; + } + + return $json; + } } diff --git a/src/Model/Validator/Factory/Composition/NotValidatorFactory.php b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php index 2f240535..77e07f1a 100644 --- a/src/Model/Validator/Factory/Composition/NotValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php @@ -5,19 +5,21 @@ namespace PHPModelGenerator\Model\Validator\Factory\Composition; use PHPModelGenerator\Exception\ComposedValue\NotException; -use PHPModelGenerator\Exception\SchemaException; +use PHPModelGenerator\Exception\Generic\DeniedPropertyException; +use PHPModelGenerator\Model\Property\CompositionPropertyDecorator; use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; +use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Validator\ComposedPropertyValidator; +use PHPModelGenerator\Model\Validator\PropertyValidator; +use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; +use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; class NotValidatorFactory extends AbstractCompositionValidatorFactory { - /** - * @throws SchemaException - */ public function modify( SchemaProcessor $schemaProcessor, Schema $schema, @@ -28,6 +30,21 @@ public function modify( return; } + $notSchema = $propertySchema->getJson()[$this->key]; + if (is_bool($notSchema)) { + if ($notSchema === true) { + $this->warnIfAlwaysFalse( + $schemaProcessor, + $property, + 'not: true negates the always-valid schema; no value is accepted', + ); + $this->buildNotTrueComposition($schemaProcessor, $schema, $property, $propertySchema); + } + + // not: false is the negation of always-invalid, so every value is accepted — no validator needed. + return; + } + // Inherit the parent type into the not branch before wrapping in array. // inheritPropertyType for 'not' treats $json['not'] as a single schema object, // so it must run before we wrap it in an array for iteration. @@ -79,4 +96,78 @@ public function modify( 100, ); } + + /** + * Build the composition that implements `not: true` semantics via NotException. + * + * The composition contains one branch with a `!array_key_exists` validator. That validator + * fires (throws) when the property is ABSENT from $modelData, causing the branch to fail and + * the not constraint to be satisfied (no exception). When the property IS present, the + * validator does not fire, the branch succeeds, and the not constraint is violated — throwing + * NotException. + */ + private function buildNotTrueComposition( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + $propertyFactory = new PropertyFactory(); + $branchSchema = $propertySchema->withJson([]); + + $branchProperty = new CompositionPropertyDecorator( + $property->getName(), + $branchSchema, + $propertyFactory->create( + $schemaProcessor, + $schema, + $property->getName(), + $branchSchema, + $property->isRequired(), + ), + ); + + $absenceCheck = "!array_key_exists('" . addslashes($property->getName()) . "', \$modelData)"; + + $branchProperty->onResolve( + function () use ($branchProperty, $absenceCheck): void { + $branchProperty->filterValidators( + static fn(Validator $validator): bool => + !is_a($validator->getValidator(), RequiredPropertyValidator::class) && + !is_a($validator->getValidator(), ComposedPropertyValidator::class), + ); + $branchProperty->addValidator( + new PropertyValidator( + $branchProperty, + $absenceCheck, + DeniedPropertyException::class, + ), + ); + }, + ); + + $compositionProperties = [$branchProperty]; + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + NotException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => 1, + 'composedValueValidation' => '$succeededCompositionElements === 0', + 'postPropose' => false, + 'mergedProperty' => null, + 'onlyForDefinedValues' => false, + ], + ), + 100, + ); + } } diff --git a/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php index 8921981a..2a55939a 100644 --- a/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php @@ -5,7 +5,6 @@ namespace PHPModelGenerator\Model\Validator\Factory\Composition; use PHPModelGenerator\Exception\ComposedValue\OneOfException; -use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Property\BaseProperty; use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; @@ -18,9 +17,6 @@ class OneOfValidatorFactory extends AbstractCompositionValidatorFactory implements ComposedPropertiesValidatorFactoryInterface { - /** - * @throws SchemaException - */ public function modify( SchemaProcessor $schemaProcessor, Schema $schema, @@ -31,6 +27,15 @@ public function modify( return; } + $branches = $propertySchema->getJson()[$this->key]; + if (!empty($branches) && array_filter($branches, static fn($branch) => $branch !== false) === []) { + $this->warnIfAlwaysFalse( + $schemaProcessor, + $property, + 'all oneOf branches are false; no value can satisfy the schema', + ); + } + $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); $propertySchema = $this->inheritPropertyType($propertySchema); diff --git a/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php index b04ff052..1271e395 100644 --- a/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php @@ -9,6 +9,7 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory; +use PHPModelGenerator\Model\Validator\ForbiddenPatternPropertiesValidator; use PHPModelGenerator\Model\Validator\PatternPropertiesValidator; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -38,6 +39,21 @@ public function modify( ); } + if ($patternSchema === true) { + continue; + } + + if ($patternSchema === false) { + $schema->addBaseValidator( + new ForbiddenPatternPropertiesValidator( + $pattern, + $schema->getClassName(), + $propertySchema, + ) + ); + continue; + } + $schema->addBaseValidator( new PatternPropertiesValidator( $schemaProcessor, diff --git a/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php index 635214db..650d46a3 100644 --- a/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php @@ -54,6 +54,16 @@ public function modify( ); } + if (isset($json['dependencies'][$propertyName])) { + throw new SchemaException( + sprintf( + "Property '%s' is denied (schema false) but also has dependencies defined in file %s", + $propertyName, + $propertySchema->getFile(), + ), + ); + } + $schema->addBaseValidator( new PropertyValidator( new Property($propertyName, null, $propertySchema->withJson([])), @@ -66,22 +76,33 @@ public function modify( $required = in_array($propertyName, $json['required'] ?? [], true); $dependencies = $json['dependencies'][$propertyName] ?? null; + + if ($propertyStructure === true) { + $nestedPropertySchema = $propertySchema->withJson([]); + } else { + $nestedPropertySchema = $propertySchema + ->navigate("$this->key/" . JsonSchema::encodePointer($propertyName)) + ->withJson( + $dependencies !== null + ? $propertyStructure + ['_dependencies' => $dependencies] + : $propertyStructure, + ); + } + $nestedProperty = $propertyFactory->create( $schemaProcessor, $schema, (string) $propertyName, - $propertySchema->navigate("$this->key/" . JsonSchema::encodePointer($propertyName))->withJson( - $dependencies !== null - ? $propertyStructure + ['_dependencies' => $dependencies] - : $propertyStructure, - ), + $nestedPropertySchema, $required, ); if ($dependencies !== null) { $this->addDependencyValidator( $nestedProperty, - $schema->getJsonSchema()->navigate("dependencies/" . JsonSchema::encodePointer($propertyName)), + $schema->getJsonSchema()->navigate( + 'dependencies/' . JsonSchema::encodePointer((string) $propertyName), + ), $schemaProcessor, $schema, ); diff --git a/src/Model/Validator/ForbiddenPatternPropertiesValidator.php b/src/Model/Validator/ForbiddenPatternPropertiesValidator.php new file mode 100644 index 00000000..83f9d89b --- /dev/null +++ b/src/Model/Validator/ForbiddenPatternPropertiesValidator.php @@ -0,0 +1,65 @@ +withJson([])), + InvalidPatternPropertiesException::class, + [$pattern, '&$invalidProperties'], + ); + } + + public function getPattern(): string + { + return $this->pattern; + } + + public function getValidatorSetUp(): string + { + return ' + $properties = $value; + $invalidProperties = []; + '; + } + + public function getCheck(): string + { + $escapedPattern = addcslashes($this->pattern, '/'); + + return << \$propertyValue) { + \$propertyKey = (string) \$propertyKey; + if (!preg_match('/$escapedPattern/', \$propertyKey)) { + continue; + } + \$invalidProperties[\$propertyKey] = [ + new \PHPModelGenerator\Exception\Generic\DeniedPropertyException(\$propertyValue, \$propertyKey), + ]; + } + return !empty(\$invalidProperties); +})() +PHP; + } +} diff --git a/src/SchemaProcessor/PostProcessor/Internal/ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor.php index d7f5df36..eaad6d1c 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor.php @@ -12,6 +12,7 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Validator\FilterValidator; +use PHPModelGenerator\Model\Validator\ForbiddenPatternPropertiesValidator; use PHPModelGenerator\Model\Validator\PatternPropertiesValidator; use PHPModelGenerator\Model\Validator\PropertyValidatorInterface; use PHPModelGenerator\PropertyProcessor\Filter\FilterProcessor; @@ -26,6 +27,7 @@ class ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor extends PostP */ public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void { + $this->checkForbiddenPatternConflicts($schema); $this->applyPatternPropertiesTypeIntersection($schema, $generatorConfiguration); $this->transferPatternPropertiesFilterToProperty($schema, $generatorConfiguration); @@ -70,6 +72,53 @@ public function getCode(PropertyInterface $property, bool $batchUpdate = false): ); } + /** + * Detect contradictions between declared properties and boolean-false patternProperties patterns. + * + * If a property is declared in `properties` AND its name matches a pattern whose schema is + * `false`, the schema is unsatisfiable: the property can never be provided without being + * denied. Throw SchemaException at generation time so the developer sees the problem immediately. + * + * @throws SchemaException + */ + private function checkForbiddenPatternConflicts(Schema $schema): void + { + $forbiddenValidators = array_filter( + $schema->getBaseValidators(), + static fn(PropertyValidatorInterface $validator): bool => + $validator instanceof ForbiddenPatternPropertiesValidator, + ); + + if (empty($forbiddenValidators)) { + return; + } + + foreach ($schema->getProperties() as $property) { + if ($property->isInternal()) { + continue; + } + + /** @var ForbiddenPatternPropertiesValidator $forbiddenValidator */ + foreach ($forbiddenValidators as $forbiddenValidator) { + $escapedPattern = addcslashes($forbiddenValidator->getPattern(), '/'); + + if (!preg_match("/$escapedPattern/", $property->getName())) { + continue; + } + + throw new SchemaException( + sprintf( + "Property '%s' is declared in properties but forbidden by patternProperties" + . " pattern '%s' in file %s", + $property->getName(), + $forbiddenValidator->getPattern(), + $property->getJsonSchema()->getFile(), + ), + ); + } + } + } + /** * For every declared/composition-transferred property whose name matches a patternProperties * pattern, intersect the property's type with the pattern's type constraint. diff --git a/src/SchemaProvider/RecursiveDirectoryProvider.php b/src/SchemaProvider/RecursiveDirectoryProvider.php index d6e9468d..97a435fb 100644 --- a/src/SchemaProvider/RecursiveDirectoryProvider.php +++ b/src/SchemaProvider/RecursiveDirectoryProvider.php @@ -55,10 +55,20 @@ public function getSchemas(): iterable foreach ($schemaFiles as $file) { $jsonSchema = file_get_contents($file); - if (!$jsonSchema || !($decodedJsonSchema = json_decode($jsonSchema, true))) { + if (!$jsonSchema) { throw new SchemaException("Invalid JSON-Schema file $file"); } + $decodedJsonSchema = json_decode($jsonSchema, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new SchemaException("Invalid JSON-Schema file $file"); + } + + if (!is_array($decodedJsonSchema)) { + continue; + } + yield new JsonSchema($file, $decodedJsonSchema); } } diff --git a/src/SchemaProvider/RefResolverTrait.php b/src/SchemaProvider/RefResolverTrait.php index 27e780fc..1aab8c59 100644 --- a/src/SchemaProvider/RefResolverTrait.php +++ b/src/SchemaProvider/RefResolverTrait.php @@ -18,10 +18,16 @@ public function getRef(string $currentFile, ?string $id, string $ref): JsonSchem throw new SchemaException("Reference to non existing JSON-Schema file $ref"); } - if (!($decodedJsonSchema = json_decode($jsonSchema, true))) { + $decodedJsonSchema = json_decode($jsonSchema, true); + + if (json_last_error() !== JSON_ERROR_NONE) { throw new SchemaException("Invalid JSON-Schema file $jsonSchemaFilePath"); } + if (!is_array($decodedJsonSchema)) { + throw new SchemaException("Referenced JSON-Schema file $jsonSchemaFilePath must contain a JSON object"); + } + return new JsonSchema($jsonSchemaFilePath, $decodedJsonSchema); } diff --git a/src/SchemaProvider/SingleFileProvider.php b/src/SchemaProvider/SingleFileProvider.php index 5b316096..d60f4355 100644 --- a/src/SchemaProvider/SingleFileProvider.php +++ b/src/SchemaProvider/SingleFileProvider.php @@ -22,12 +22,21 @@ public function __construct(private string $sourceFile) { $this->sourceFile = realpath($this->sourceFile) ?: $this->sourceFile; $jsonSchemaContent = @file_get_contents($this->sourceFile); - $decoded = $jsonSchemaContent !== false ? json_decode($jsonSchemaContent, true) : null; - if (!$decoded) { + if ($jsonSchemaContent === false) { throw new SchemaException("Invalid JSON-Schema file {$this->sourceFile}"); } + $decoded = json_decode($jsonSchemaContent, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new SchemaException("Invalid JSON-Schema file {$this->sourceFile}"); + } + + if (!is_array($decoded)) { + throw new SchemaException("JSON-Schema file {$this->sourceFile} must contain a JSON object"); + } + $this->schema = $decoded; } diff --git a/tests/Basic/BooleanCompositionSchemaTest.php b/tests/Basic/BooleanCompositionSchemaTest.php new file mode 100644 index 00000000..82de73c0 --- /dev/null +++ b/tests/Basic/BooleanCompositionSchemaTest.php @@ -0,0 +1,300 @@ +expectException(SchemaException::class); + $this->expectExceptionMessageMatches($expectedMessagePattern); + $this->generateClassFromFile($schemaFile); + } + + public static function unsatisfiableConditionalDataProvider(): array + { + return [ + // if: {string}, then: false — any string value would always fail the then branch + 'if:string then:false' => ['IfStringThenFalse.json', '/then: false is unsatisfiable for property value/i'], + // if: {string}, else: false — any non-string would always fail the else branch + 'if:string else:false' => ['IfStringElseFalse.json', '/else: false is unsatisfiable for property value/i'], + ]; + } + + /** + * When a composition is always unsatisfiable (all branches are always-false, or not/if + * structures that can never be satisfied), the generator routes through the composition + * framework so a proper composition exception is thrown at runtime. + * + * - allOf/anyOf/oneOf: AllOf/AnyOf/OneOfException ("Invalid value for value") + * - not: true: NotException ("Invalid value for value") + * - if/then/else always-false: ConditionalException ("Invalid value for value") + */ + #[DataProvider('alwaysFalseCompositionExceptionDataProvider')] + public function testAlwaysFalseCompositionThrowsCompositionException( + GeneratorConfiguration $configuration, + string $schemaFile, + mixed $value, + ): void { + $this->expectValidationError($configuration, 'Invalid value for value'); + + $className = $this->generateClassFromFile($schemaFile, $configuration); + new $className(['value' => $value]); + } + + public static function alwaysFalseCompositionExceptionDataProvider(): array + { + return self::combineDataProvider( + self::validationMethodDataProvider(), + [ + // allOf: any false branch makes the whole schema unsatisfiable + 'allOf false+real, string' => ['AllOfFalseBranch.json', 'hello'], + 'allOf false+real, int' => ['AllOfFalseBranch.json', 42], + 'allOf false+real, null' => ['AllOfFalseBranch.json', null], + // anyOf: all false branches — no branch can ever satisfy + 'anyOf all false, string' => ['AnyOfAllFalse.json', 'hello'], + 'anyOf all false, int' => ['AnyOfAllFalse.json', 42], + // oneOf: all false branches — no branch can ever satisfy + 'oneOf all false, string' => ['OneOfAllFalse.json', 'hello'], + 'oneOf all false, int' => ['OneOfAllFalse.json', 42], + // not: true — negation of always-valid schema; NotException is thrown + 'not true, string' => ['NotTrue.json', 'hello'], + 'not true, int' => ['NotTrue.json', 42], + // if: false, else: false — always-unsatisfiable; ConditionalException is thrown + 'if:false else:false, str' => ['IfFalseElseFalse.json', 'hello'], + 'if:false else:false, int' => ['IfFalseElseFalse.json', 42], + // if: true, then: false — always-unsatisfiable; ConditionalException is thrown + 'if:true then:false, str' => ['IfTrueThenFalse.json', 'hello'], + 'if:true then:false, int' => ['IfTrueThenFalse.json', 42], + ], + ); + } + + #[DataProvider('alwaysFalseAbsentPropertyDataProvider')] + public function testAlwaysFalseCompositionAllowsAbsentProperty(string $schemaFile): void + { + $className = $this->generateClassFromFile($schemaFile); + $object = new $className([]); + $this->assertNull($object->getValue()); + } + + public static function alwaysFalseAbsentPropertyDataProvider(): array + { + return [ + // allOf: false branch makes composition unsatisfiable + 'allOf false branch' => ['AllOfFalseBranch.json'], + // anyOf: all false — no branch can ever satisfy + 'anyOf all false' => ['AnyOfAllFalse.json'], + // oneOf: all false — no branch can ever satisfy + 'oneOf all false' => ['OneOfAllFalse.json'], + // not: true — negation of always-valid schema + 'not true' => ['NotTrue.json'], + // if: false, else: false — always unsatisfiable + 'if false else false' => ['IfFalseElseFalse.json'], + // if: true, then: false — always unsatisfiable + 'if true then false' => ['IfTrueThenFalse.json'], + ]; + } + + /** + * Boolean branches participate in the composition and affect validation outcomes. + * + * - false branches always fail (counted as a failing element) + * - true branches always succeed (counted as a passing element) + * + * This tests scenarios where one boolean branch coexists with a real schema branch, and + * verifies the composition exception fires when the overall composition constraint is unmet. + * Covers allOf (true branch), anyOf/oneOf (false branch), and if/then/else with boolean if. + */ + #[DataProvider('booleanBranchParticipatesDataProvider')] + public function testBooleanBranchParticipatesInComposition( + GeneratorConfiguration $configuration, + string $schemaFile, + mixed $value, + bool $valid, + ): void { + if (!$valid) { + $this->expectValidationError($configuration, 'Invalid value for value'); + } + + $className = $this->generateClassFromFile($schemaFile, $configuration); + $object = new $className(['value' => $value]); + $this->assertSame($value, $object->getValue()); + } + + public static function booleanBranchParticipatesDataProvider(): array + { + return self::combineDataProvider( + self::validationMethodDataProvider(), + [ + // allOf: true branch always passes; real string branch is still enforced because + // allOf requires ALL branches to pass + 'allOf true+real, valid string' => ['AllOfTrueBranch.json', 'hello', true], + 'allOf true+real, invalid int' => ['AllOfTrueBranch.json', 42, false], + // anyOf: false branch always fails; real string branch is still enforced because + // anyOf requires at LEAST ONE branch to pass + 'anyOf false+real, valid string' => ['AnyOfFalseBranchWithReal.json', 'hello', true], + 'anyOf false+real, invalid int' => ['AnyOfFalseBranchWithReal.json', 42, false], + // oneOf: false branch always fails; real string branch is still enforced because + // oneOf requires EXACTLY ONE branch to pass (false branch never counts) + 'oneOf false+real, valid string' => ['OneOfFalseBranchWithReal.json', 'hello', true], + 'oneOf false+real, invalid int' => ['OneOfFalseBranchWithReal.json', 42, false], + // if: false, else: string → else always applies + 'if:false else:string, valid string' => ['IfFalseElseString.json', 'hello', true], + 'if:false else:string, invalid int' => ['IfFalseElseString.json', 42, false], + // if: true, then: string → then always applies + 'if:true then:string, valid string' => ['IfTrueThenString.json', 'hello', true], + 'if:true then:string, invalid int' => ['IfTrueThenString.json', 42, false], + ], + ); + } + + /** + * A true branch in anyOf/oneOf participates in the composition count and appears in + * exception messages when the overall composition constraint is violated. + * + * - anyOf: [true, real] always passes because true always satisfies "at least one" — the real + * branch becomes irrelevant for pass/fail, but true IS counted as a matched element. + * - oneOf: [true, real] with a value that ALSO satisfies the real branch causes BOTH branches + * to match, violating "exactly one" — the true branch is counted and pushes the match count + * to 2, surfacing a OneOfException even for otherwise-valid values. + */ + #[DataProvider('trueBranchCompositionDataProvider')] + public function testTrueBranchParticipatesInComposition( + GeneratorConfiguration $configuration, + string $schemaFile, + mixed $value, + bool $valid, + ): void { + if (!$valid) { + $this->expectValidationError($configuration, 'Invalid value for value'); + } + + $className = $this->generateClassFromFile($schemaFile, $configuration); + $object = new $className(['value' => $value]); + $this->assertSame($value, $object->getValue()); + } + + public static function trueBranchCompositionDataProvider(): array + { + return self::combineDataProvider( + self::validationMethodDataProvider(), + [ + // anyOf: true branch always satisfies "at least one" — even a value that would + // fail the real branch passes the overall anyOf + 'anyOf true+real, valid string' => ['AnyOfTrueBranchWithReal.json', 'hello', true], + 'anyOf true+real, invalid int' => ['AnyOfTrueBranchWithReal.json', 42, true], + // oneOf: true branch always matches, so any value that also matches the real + // string branch makes TWO branches succeed → OneOfException (need exactly one) + 'oneOf true+real, valid string' => ['OneOfTrueBranchWithReal.json', 'hello', false], + // int does not match the real string branch, so only true matches → exactly one → valid + 'oneOf true+real, invalid int' => ['OneOfTrueBranchWithReal.json', 42, true], + ], + ); + } + + public function testNotFalseAcceptsAnyValue(): void + { + $className = $this->generateClassFromFile('NotFalse.json'); + + $object = new $className(['value' => 'hello']); + $this->assertSame('hello', $object->getValue()); + + $object2 = new $className(['value' => 42]); + $this->assertSame(42, $object2->getValue()); + } + + public function testIfStringThenTrueAcceptsStringWithNoConstraint(): void + { + $className = $this->generateClassFromFile('IfStringThenTrue.json'); + + // string matches if, then: true imposes no constraint + $object = new $className(['value' => 'hello']); + $this->assertSame('hello', $object->getValue()); + + // int does not match if, no else to apply + $object2 = new $className(['value' => 42]); + $this->assertSame(42, $object2->getValue()); + } + + public function testIfStringElseTrueAcceptsNonStringWithNoConstraint(): void + { + $className = $this->generateClassFromFile('IfStringElseTrue.json'); + + // string matches if, no then to apply + $object = new $className(['value' => 'hello']); + $this->assertSame('hello', $object->getValue()); + + // int does not match if, else: true imposes no constraint + $object2 = new $className(['value' => 42]); + $this->assertSame(42, $object2->getValue()); + } + + /** + * if: false with a then but no else — condition never matches so then never applies. + * The whole if/then/else imposes no constraint on the property. + */ + public function testIfFalseWithThenButNoElseImposesNoConstraint(): void + { + $className = $this->generateClassFromFile('IfFalseNoElse.json'); + + $object = new $className(['value' => 'hello']); + $this->assertSame('hello', $object->getValue()); + + $object2 = new $className(['value' => 42]); + $this->assertSame(42, $object2->getValue()); + + // Absent property is also fine + $object3 = new $className([]); + $this->assertNull($object3->getValue()); + } + + /** + * if: true with an else but no then — condition always matches so else never applies. + * The whole if/then/else imposes no constraint on the property. + */ + public function testIfTrueWithElseButNoThenImposesNoConstraint(): void + { + $className = $this->generateClassFromFile('IfTrueNoThen.json'); + + $object = new $className(['value' => 'hello']); + $this->assertSame('hello', $object->getValue()); + + $object2 = new $className(['value' => 42]); + $this->assertSame(42, $object2->getValue()); + + $object3 = new $className([]); + $this->assertNull($object3->getValue()); + } + + /** + * allOf: [true, true] — all branches always succeed; the composition imposes no constraint. + */ + public function testAllOfOnlyTrueBranchesAcceptsAnything(): void + { + $className = $this->generateClassFromFile('AllOfTrueOnly.json'); + + $object = new $className(['value' => 'hello']); + $this->assertSame('hello', $object->getValue()); + + $object2 = new $className(['value' => 42]); + $this->assertSame(42, $object2->getValue()); + + $object3 = new $className([]); + $this->assertNull($object3->getValue()); + } +} diff --git a/tests/Basic/BooleanContainsSchemaTest.php b/tests/Basic/BooleanContainsSchemaTest.php new file mode 100644 index 00000000..7b2479a4 --- /dev/null +++ b/tests/Basic/BooleanContainsSchemaTest.php @@ -0,0 +1,61 @@ +generateClassFromFile('FalseContains.json'); + $object = new $className([]); + $this->assertNull($object->getProperty()); + } + + #[DataProvider('containsFalseDataProvider')] + public function testContainsFalseRejectsAnyArray( + GeneratorConfiguration $configuration, + array $value, + ): void { + $this->expectValidationError($configuration, 'No item in array property matches contains constraint'); + + $className = $this->generateClassFromFile('FalseContains.json', $configuration); + new $className(['property' => $value]); + } + + public static function containsFalseDataProvider(): array + { + return self::combineDataProvider( + self::validationMethodDataProvider(), + [ + 'empty array' => [[]], + 'non-empty array' => [[1, 2, 3]], + 'mixed array' => [['hello', true, null]], + ], + ); + } + + #[DataProvider('validationMethodDataProvider')] + public function testContainsTrueAcceptsNonEmptyArray(GeneratorConfiguration $configuration): void + { + $className = $this->generateClassFromFile('TrueContains.json', $configuration); + + $object = new $className(['property' => [1, 'hello', true]]); + $this->assertSame([1, 'hello', true], $object->getProperty()); + } + + #[DataProvider('validationMethodDataProvider')] + public function testContainsTrueRejectsEmptyArray(GeneratorConfiguration $configuration): void + { + $this->expectValidationError($configuration, 'No item in array property matches contains constraint'); + + $className = $this->generateClassFromFile('TrueContains.json', $configuration); + + new $className(['property' => []]); + } +} diff --git a/tests/Basic/BooleanItemsSchemaTest.php b/tests/Basic/BooleanItemsSchemaTest.php new file mode 100644 index 00000000..ee978149 --- /dev/null +++ b/tests/Basic/BooleanItemsSchemaTest.php @@ -0,0 +1,55 @@ +generateClassFromFile('TrueItems.json'); + + $object = new $className(['items' => [1, 'hello', true, null]]); + $this->assertSame([1, 'hello', true, null], $object->getItems()); + } + + public function testItemsFalseAcceptsEmptyArray(): void + { + $className = $this->generateClassFromFile('FalseItems.json'); + + $object = new $className(['items' => []]); + $this->assertSame([], $object->getItems()); + } + + #[DataProvider('nonEmptyArrayDataProvider')] + public function testItemsFalseRejectsNonEmptyArray( + GeneratorConfiguration $configuration, + array $value, + ): void { + $this->expectValidationError( + $configuration, + 'Array items must not contain more than 0 items', + ); + + $className = $this->generateClassFromFile('FalseItems.json', $configuration); + new $className(['items' => $value]); + } + + public static function nonEmptyArrayDataProvider(): array + { + return self::combineDataProvider( + self::validationMethodDataProvider(), + [ + 'single integer item' => [[42]], + 'single string item' => [['hello']], + 'multiple items' => [[1, 2, 3]], + 'mixed items' => [[true, null, 'x']], + ], + ); + } +} diff --git a/tests/Basic/BooleanPatternPropertySchemaTest.php b/tests/Basic/BooleanPatternPropertySchemaTest.php new file mode 100644 index 00000000..3598260c --- /dev/null +++ b/tests/Basic/BooleanPatternPropertySchemaTest.php @@ -0,0 +1,90 @@ +expectValidationError( + $configuration, + "invalid property 'secret_value' matching pattern '^secret_.*'", + ); + $className = $this->generateClassFromFile('FalsePatternProperty.json', $configuration); + new $className(['secret_value' => $value]); + } + + public static function falsePatternMatchingKeyDataProvider(): array + { + return self::combineDataProvider( + self::validationMethodDataProvider(), + [ + 'string value' => ['hello'], + 'int value' => [42], + 'null value' => [null], + 'bool value' => [true], + 'array value' => [[]], + ], + ); + } + + public function testDeclaredPropertyMatchingForbiddenPatternThrowsSchemaException(): void + { + $messagePattern = '/^Property \'secret_data\' is declared in properties' + . ' but forbidden by patternProperties pattern \'\^secret_\.\*\' in file/'; + + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches($messagePattern); + $this->generateClassFromFile('ForbiddenPatternConflict.json'); + } + + public function testFalsePatternPropertyAllowsNonMatchingKey(): void + { + $className = $this->generateClassFromFile('FalsePatternProperty.json'); + $object = new $className(['public_value' => 'hello', 'name' => 'Alice']); + $this->assertSame(['public_value' => 'hello', 'name' => 'Alice'], $object->getRawModelDataInput()); + } + + public function testTruePatternPropertyAcceptsMatchingKey(): void + { + $className = $this->generateClassFromFile('TruePatternProperty.json'); + $object = new $className(['any_field' => 'whatever', 'name' => 'Bob']); + $this->assertSame(['any_field' => 'whatever', 'name' => 'Bob'], $object->getRawModelDataInput()); + } + + /** + * When multiple keys match a false pattern, all of them are collected and reported + * in a single InvalidPatternPropertiesException (not just the first match). + * + * Even in early-return mode the validator iterates all matching keys before throwing, + * so both keys appear in the exception message. + */ + public function testFalsePatternDeniesAllMatchingKeysInOneException(): void + { + $className = $this->generateClassFromFile('FalsePatternMultipleMatches.json'); + + $this->expectException(InvalidPatternPropertiesException::class); + $this->expectExceptionMessage( + << 'x', 'secret_b' => 'y', 'public' => 'ok']); + } +} diff --git a/tests/Basic/BooleanPropertySchemaTest.php b/tests/Basic/BooleanPropertySchemaTest.php new file mode 100644 index 00000000..72411537 --- /dev/null +++ b/tests/Basic/BooleanPropertySchemaTest.php @@ -0,0 +1,121 @@ +expectException(SchemaException::class); + $this->expectExceptionMessageMatches($expectedMessagePattern); + $this->generateClassFromFile($schemaFile); + } + + public static function invalidFalsePropertySchemaDataProvider(): array + { + return [ + // property denied by boolean false but also listed in required[] + 'required false property' => [ + 'RequiredFalseProperty.json', + "/^Property 'forbidden' is denied \(schema false\) but also listed as required in file/", + ], + // property denied by boolean false but also has dependencies defined + 'false property with dependency' => [ + 'FalsePropertyWithDependency.json', + "/^Property 'forbidden' is denied \(schema false\) but also has dependencies defined in file/", + ], + ]; + } + + public function testNotProvidingFalsePropertyIsValid(): void + { + $className = $this->generateClassFromFile('FalseProperty.json'); + + $object = new $className(['name' => 'Alice']); + $this->assertSame('Alice', $object->getName()); + + // No getter is generated for the denied property + $this->assertFalse(method_exists($className, 'getForbidden')); + } + + #[DataProvider('falsePropertyValueDataProvider')] + public function testProvidingFalsePropertyThrowsException( + GeneratorConfiguration $configuration, + mixed $value, + ): void { + $this->expectValidationError($configuration, 'Value for forbidden is not allowed'); + $className = $this->generateClassFromFile('FalseProperty.json', $configuration); + new $className(['forbidden' => $value]); + } + + public static function falsePropertyValueDataProvider(): array + { + return self::combineDataProvider( + self::validationMethodDataProvider(), + self::anyValueDataProvider(), + ); + } + + public static function anyValueDataProvider(): array + { + return [ + 'string' => ['hello'], + 'int' => [42], + 'null' => [null], + 'bool' => [true], + 'array' => [[]], + ]; + } + + #[DataProvider('anyValueDataProvider')] + public function testTruePropertyAcceptsAnyValue(mixed $value): void + { + $className = $this->generateClassFromFile('TrueProperty.json'); + $object = new $className(['anything' => $value]); + $this->assertSame($value, $object->getAnything()); + } + + public function testTruePropertyGetterHasMixedReturnType(): void + { + $className = $this->generateClassFromFile('TrueProperty.json'); + $returnType = $this->getReturnType($className, 'getAnything'); + $this->assertNotNull($returnType); + $this->assertSame('mixed', $returnType->getName()); + } + + public function testTruePropertyHonoursDependency(): void + { + $className = $this->generateClassFromFile('TruePropertyWithDependency.json'); + + // Absent entirely — valid + $object = new $className([]); + $this->assertNull($object->getAnything()); + + // Providing 'other' alone without 'anything' — valid + $object = new $className(['other' => 'hello']); + $this->assertSame('hello', $object->getOther()); + + // Providing both — valid + $object = new $className(['anything' => 42, 'other' => 'hello']); + $this->assertSame(42, $object->getAnything()); + } + + #[DataProvider('validationMethodDataProvider')] + public function testTruePropertyDependencyIsEnforced(GeneratorConfiguration $configuration): void + { + $this->expectValidationError($configuration, "Missing required attributes which are dependants of anything"); + + $className = $this->generateClassFromFile('TruePropertyWithDependency.json', $configuration); + new $className(['anything' => 'hello']); + } +} diff --git a/tests/Basic/FalsePropertySchemaTest.php b/tests/Basic/FalsePropertySchemaTest.php deleted file mode 100644 index 4ec3625a..00000000 --- a/tests/Basic/FalsePropertySchemaTest.php +++ /dev/null @@ -1,62 +0,0 @@ -expectException(SchemaException::class); - $this->expectExceptionMessageMatches("/forbidden.*denied.*required/i"); - $this->generateClassFromFile('RequiredFalseProperty.json'); - } - - public function testNotProvidingFalsePropertyIsValid(): void - { - $className = $this->generateClassFromFile('FalseProperty.json'); - $object = new $className(['name' => 'Alice']); - $this->assertSame('Alice', $object->getName()); - } - - #[DataProvider('falsePropertyValueDataProvider')] - public function testProvidingFalsePropertyThrowsException( - GeneratorConfiguration $configuration, - mixed $value, - ): void { - $this->expectValidationError($configuration, 'Value for forbidden is not allowed'); - $className = $this->generateClassFromFile('FalseProperty.json', $configuration); - new $className(['forbidden' => $value]); - } - - public static function falsePropertyValueDataProvider(): array - { - return self::combineDataProvider( - self::validationMethodDataProvider(), - [ - 'string' => ['hello'], - 'int' => [42], - 'null' => [null], - 'bool' => [true], - 'array' => [[]], - ], - ); - } - - public function testNoGetterGeneratedForFalseProperty(): void - { - $className = $this->generateClassFromFile('FalseProperty.json'); - $this->assertFalse(method_exists($className, 'getForbidden')); - } -} diff --git a/tests/Schema/BooleanCompositionSchemaTest/AllOfFalseBranch.json b/tests/Schema/BooleanCompositionSchemaTest/AllOfFalseBranch.json new file mode 100644 index 00000000..bdf8ec8e --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/AllOfFalseBranch.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "value": { + "allOf": [ + false, + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/AllOfTrueBranch.json b/tests/Schema/BooleanCompositionSchemaTest/AllOfTrueBranch.json new file mode 100644 index 00000000..1faeef04 --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/AllOfTrueBranch.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "value": { + "allOf": [ + true, + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/AllOfTrueOnly.json b/tests/Schema/BooleanCompositionSchemaTest/AllOfTrueOnly.json new file mode 100644 index 00000000..1e9857e2 --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/AllOfTrueOnly.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "value": { + "allOf": [ + true, + true + ] + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/AnyOfAllFalse.json b/tests/Schema/BooleanCompositionSchemaTest/AnyOfAllFalse.json new file mode 100644 index 00000000..7d71bd6a --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/AnyOfAllFalse.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "value": { + "anyOf": [ + false, + false + ] + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/AnyOfFalseBranchWithReal.json b/tests/Schema/BooleanCompositionSchemaTest/AnyOfFalseBranchWithReal.json new file mode 100644 index 00000000..d7e18343 --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/AnyOfFalseBranchWithReal.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "value": { + "anyOf": [ + false, + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/AnyOfTrueBranchWithReal.json b/tests/Schema/BooleanCompositionSchemaTest/AnyOfTrueBranchWithReal.json new file mode 100644 index 00000000..e861bfeb --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/AnyOfTrueBranchWithReal.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "value": { + "anyOf": [ + true, + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/IfFalseElseFalse.json b/tests/Schema/BooleanCompositionSchemaTest/IfFalseElseFalse.json new file mode 100644 index 00000000..f8ec1865 --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/IfFalseElseFalse.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "value": { + "if": false, + "else": false + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/IfFalseElseString.json b/tests/Schema/BooleanCompositionSchemaTest/IfFalseElseString.json new file mode 100644 index 00000000..1da26fcb --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/IfFalseElseString.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "value": { + "if": false, + "else": { + "type": "string" + } + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/IfFalseNoElse.json b/tests/Schema/BooleanCompositionSchemaTest/IfFalseNoElse.json new file mode 100644 index 00000000..cf9855ce --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/IfFalseNoElse.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "value": { + "if": false, + "then": { + "type": "string" + } + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/IfStringElseFalse.json b/tests/Schema/BooleanCompositionSchemaTest/IfStringElseFalse.json new file mode 100644 index 00000000..0be7ba59 --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/IfStringElseFalse.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "value": { + "if": { + "type": "string" + }, + "else": false + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/IfStringElseTrue.json b/tests/Schema/BooleanCompositionSchemaTest/IfStringElseTrue.json new file mode 100644 index 00000000..02a68374 --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/IfStringElseTrue.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "value": { + "if": { + "type": "string" + }, + "else": true + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/IfStringThenFalse.json b/tests/Schema/BooleanCompositionSchemaTest/IfStringThenFalse.json new file mode 100644 index 00000000..4ff4821e --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/IfStringThenFalse.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "value": { + "if": { + "type": "string" + }, + "then": false + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/IfStringThenTrue.json b/tests/Schema/BooleanCompositionSchemaTest/IfStringThenTrue.json new file mode 100644 index 00000000..15eb3796 --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/IfStringThenTrue.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "value": { + "if": { + "type": "string" + }, + "then": true + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/IfTrueNoThen.json b/tests/Schema/BooleanCompositionSchemaTest/IfTrueNoThen.json new file mode 100644 index 00000000..18601a6d --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/IfTrueNoThen.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "value": { + "if": true, + "else": { + "type": "string" + } + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/IfTrueThenFalse.json b/tests/Schema/BooleanCompositionSchemaTest/IfTrueThenFalse.json new file mode 100644 index 00000000..63764c7e --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/IfTrueThenFalse.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "value": { + "if": true, + "then": false + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/IfTrueThenString.json b/tests/Schema/BooleanCompositionSchemaTest/IfTrueThenString.json new file mode 100644 index 00000000..9d85bcf1 --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/IfTrueThenString.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "value": { + "if": true, + "then": { + "type": "string" + } + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/NotFalse.json b/tests/Schema/BooleanCompositionSchemaTest/NotFalse.json new file mode 100644 index 00000000..167a29da --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/NotFalse.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "value": { + "not": false + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/NotTrue.json b/tests/Schema/BooleanCompositionSchemaTest/NotTrue.json new file mode 100644 index 00000000..e0f9700b --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/NotTrue.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "value": { + "not": true + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/OneOfAllFalse.json b/tests/Schema/BooleanCompositionSchemaTest/OneOfAllFalse.json new file mode 100644 index 00000000..8bde49b3 --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/OneOfAllFalse.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "value": { + "oneOf": [ + false, + false + ] + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/OneOfFalseBranchWithReal.json b/tests/Schema/BooleanCompositionSchemaTest/OneOfFalseBranchWithReal.json new file mode 100644 index 00000000..26335dcd --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/OneOfFalseBranchWithReal.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "value": { + "oneOf": [ + false, + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/BooleanCompositionSchemaTest/OneOfTrueBranchWithReal.json b/tests/Schema/BooleanCompositionSchemaTest/OneOfTrueBranchWithReal.json new file mode 100644 index 00000000..470713e0 --- /dev/null +++ b/tests/Schema/BooleanCompositionSchemaTest/OneOfTrueBranchWithReal.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "value": { + "oneOf": [ + true, + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/BooleanContainsSchemaTest/FalseContains.json b/tests/Schema/BooleanContainsSchemaTest/FalseContains.json new file mode 100644 index 00000000..62b52cff --- /dev/null +++ b/tests/Schema/BooleanContainsSchemaTest/FalseContains.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "array", + "contains": false + } + } +} diff --git a/tests/Schema/BooleanContainsSchemaTest/TrueContains.json b/tests/Schema/BooleanContainsSchemaTest/TrueContains.json new file mode 100644 index 00000000..83d77d90 --- /dev/null +++ b/tests/Schema/BooleanContainsSchemaTest/TrueContains.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "array", + "contains": true + } + } +} diff --git a/tests/Schema/BooleanItemsSchemaTest/FalseItems.json b/tests/Schema/BooleanItemsSchemaTest/FalseItems.json new file mode 100644 index 00000000..7c7fb2a8 --- /dev/null +++ b/tests/Schema/BooleanItemsSchemaTest/FalseItems.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": false + } + } +} diff --git a/tests/Schema/BooleanItemsSchemaTest/TrueItems.json b/tests/Schema/BooleanItemsSchemaTest/TrueItems.json new file mode 100644 index 00000000..c74b209f --- /dev/null +++ b/tests/Schema/BooleanItemsSchemaTest/TrueItems.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": true + } + } +} diff --git a/tests/Schema/BooleanPatternPropertySchemaTest/FalsePatternMultipleMatches.json b/tests/Schema/BooleanPatternPropertySchemaTest/FalsePatternMultipleMatches.json new file mode 100644 index 00000000..49263a27 --- /dev/null +++ b/tests/Schema/BooleanPatternPropertySchemaTest/FalsePatternMultipleMatches.json @@ -0,0 +1,6 @@ +{ + "type": "object", + "patternProperties": { + "^secret_.*": false + } +} diff --git a/tests/Schema/BooleanPatternPropertySchemaTest/FalsePatternProperty.json b/tests/Schema/BooleanPatternPropertySchemaTest/FalsePatternProperty.json new file mode 100644 index 00000000..58925bb9 --- /dev/null +++ b/tests/Schema/BooleanPatternPropertySchemaTest/FalsePatternProperty.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "patternProperties": { + "^secret_.*": false + } +} diff --git a/tests/Schema/BooleanPatternPropertySchemaTest/ForbiddenPatternConflict.json b/tests/Schema/BooleanPatternPropertySchemaTest/ForbiddenPatternConflict.json new file mode 100644 index 00000000..f880f09c --- /dev/null +++ b/tests/Schema/BooleanPatternPropertySchemaTest/ForbiddenPatternConflict.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "secret_data": { + "type": "string" + } + }, + "patternProperties": { + "^secret_.*": false + } +} diff --git a/tests/Schema/BooleanPatternPropertySchemaTest/TruePatternProperty.json b/tests/Schema/BooleanPatternPropertySchemaTest/TruePatternProperty.json new file mode 100644 index 00000000..8970af45 --- /dev/null +++ b/tests/Schema/BooleanPatternPropertySchemaTest/TruePatternProperty.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "patternProperties": { + "^any_.*": true + } +} diff --git a/tests/Schema/FalsePropertySchemaTest/FalseProperty.json b/tests/Schema/BooleanPropertySchemaTest/FalseProperty.json similarity index 100% rename from tests/Schema/FalsePropertySchemaTest/FalseProperty.json rename to tests/Schema/BooleanPropertySchemaTest/FalseProperty.json diff --git a/tests/Schema/BooleanPropertySchemaTest/FalsePropertyWithDependency.json b/tests/Schema/BooleanPropertySchemaTest/FalsePropertyWithDependency.json new file mode 100644 index 00000000..60d5eec9 --- /dev/null +++ b/tests/Schema/BooleanPropertySchemaTest/FalsePropertyWithDependency.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "forbidden": false, + "other": { + "type": "string" + } + }, + "dependencies": { + "forbidden": [ + "other" + ] + } +} diff --git a/tests/Schema/FalsePropertySchemaTest/RequiredFalseProperty.json b/tests/Schema/BooleanPropertySchemaTest/RequiredFalseProperty.json similarity index 100% rename from tests/Schema/FalsePropertySchemaTest/RequiredFalseProperty.json rename to tests/Schema/BooleanPropertySchemaTest/RequiredFalseProperty.json diff --git a/tests/Schema/BooleanPropertySchemaTest/TrueProperty.json b/tests/Schema/BooleanPropertySchemaTest/TrueProperty.json new file mode 100644 index 00000000..44b25876 --- /dev/null +++ b/tests/Schema/BooleanPropertySchemaTest/TrueProperty.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "anything": true + } +} diff --git a/tests/Schema/BooleanPropertySchemaTest/TruePropertyWithDependency.json b/tests/Schema/BooleanPropertySchemaTest/TruePropertyWithDependency.json new file mode 100644 index 00000000..0e2c97ca --- /dev/null +++ b/tests/Schema/BooleanPropertySchemaTest/TruePropertyWithDependency.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "anything": true, + "other": { + "type": "string" + } + }, + "dependencies": { + "anything": [ + "other" + ] + } +} diff --git a/tests/SchemaProvider/RecursiveDirectoryProviderTest.php b/tests/SchemaProvider/RecursiveDirectoryProviderTest.php new file mode 100644 index 00000000..bda22bcc --- /dev/null +++ b/tests/SchemaProvider/RecursiveDirectoryProviderTest.php @@ -0,0 +1,76 @@ +schemaDir = sys_get_temp_dir() . '/PHPModelGeneratorTest/RecursiveDirectoryProviderTest/schemas'; + $this->outputDir = sys_get_temp_dir() . '/PHPModelGeneratorTest/RecursiveDirectoryProviderTest/output'; + + @mkdir($this->schemaDir, 0777, true); + @mkdir($this->outputDir, 0777, true); + } + + public function tearDown(): void + { + $this->removeDirectory($this->schemaDir); + $this->removeDirectory($this->outputDir); + } + + /** + * Files whose JSON decodes to a non-object value (boolean, number, string, null) are silently + * skipped — consistent with how SchemaProcessor skips non-object schemas. + */ + public function testNonObjectJsonFilesAreSkipped(): void + { + file_put_contents($this->schemaDir . '/valid.json', json_encode([ + 'type' => 'object', + 'properties' => ['name' => ['type' => 'string']], + ])); + file_put_contents($this->schemaDir . '/true_schema.json', 'true'); + file_put_contents($this->schemaDir . '/false_schema.json', 'false'); + file_put_contents($this->schemaDir . '/number_schema.json', '42'); + file_put_contents($this->schemaDir . '/string_schema.json', '"hello"'); + file_put_contents($this->schemaDir . '/null_schema.json', 'null'); + + (new ModelGenerator( + (new GeneratorConfiguration())->setOutputEnabled(false), + ))->generateModels( + new RecursiveDirectoryProvider($this->schemaDir), + $this->outputDir, + ); + + $this->assertTrue(file_exists($this->outputDir . '/Valid.php')); + $this->assertCount(1, glob($this->outputDir . '/*.php')); + } + + private function removeDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + $item->isDir() ? rmdir($item->getRealPath()) : unlink($item->getRealPath()); + } + + rmdir($path); + } +} diff --git a/tests/SchemaProvider/SingleFileProviderTest.php b/tests/SchemaProvider/SingleFileProviderTest.php index e8c9246a..0c7e6be8 100644 --- a/tests/SchemaProvider/SingleFileProviderTest.php +++ b/tests/SchemaProvider/SingleFileProviderTest.php @@ -9,6 +9,7 @@ use PHPModelGenerator\ModelGenerator; use PHPModelGenerator\SchemaProvider\SingleFileProvider; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; +use PHPUnit\Framework\Attributes\DataProvider; /** * Class SingleFileProviderTest @@ -58,25 +59,52 @@ public function testSingleFileProviderGeneratesClass(): void * Construction fails with a SchemaException for a non-existing file and for a file * containing invalid JSON — both represent an unusable schema source. */ - public function testInvalidSourceThrowsSchemaException(): void + #[DataProvider('invalidSourceDataProvider')] + public function testInvalidSourceThrowsSchemaException(string $filePath): void { - // Non-existing file - try { - new SingleFileProvider('/non/existing/path.json'); - $this->fail('Expected SchemaException for non-existing file'); - } catch (SchemaException $schemaException) { - $this->assertMatchesRegularExpression('/^Invalid JSON-Schema file/', $schemaException->getMessage()); - } + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches('/^Invalid JSON-Schema file/'); + new SingleFileProvider($filePath); + } + + public static function invalidSourceDataProvider(): array + { + return [ + 'non-existing file' => ['/non/existing/path.json'], + 'invalid JSON file' => [__DIR__ . '/../Schema/SingleFileProviderTest/InvalidJSON.json'], + ]; + } + + /** + * A file whose JSON decodes to a non-object value (boolean, number, etc.) throws + * SchemaException — SingleFileProvider was given an explicit path and must report the problem. + */ + #[DataProvider('nonObjectJsonDataProvider')] + public function testNonObjectSchemaThrowsSchemaException(string $jsonContent): void + { + $tempFile = sys_get_temp_dir() . '/phpModelGenTest_' . uniqid() . '.json'; + file_put_contents($tempFile, $jsonContent); - // File containing invalid JSON try { - new SingleFileProvider($this->getSchemaFilePath('InvalidJSON.json')); - $this->fail('Expected SchemaException for invalid JSON file'); - } catch (SchemaException $schemaException) { - $this->assertMatchesRegularExpression('/^Invalid JSON-Schema file/', $schemaException->getMessage()); + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches('/must contain a JSON object/'); + new SingleFileProvider($tempFile); + } finally { + @unlink($tempFile); } } + public static function nonObjectJsonDataProvider(): array + { + return [ + 'boolean true' => ['true'], + 'boolean false' => ['false'], + 'integer' => ['42'], + 'string' => ['"hello"'], + 'null' => ['null'], + ]; + } + /** * A schema with a relative $ref to a sibling file generates both classes and correctly * wires up the nested object — verifying that RefResolverTrait resolves relative paths