diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0094988b..73b47827 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,15 +4,12 @@ "Bash(gh issue:*)", "WebFetch(domain:github.com)", "Bash(find:*)", - "Bash(cd C:/git/php-json-schema-model-generator && /c/PHP84/php.exe ./vendor/bin/phpunit 2>&1)", - "Bash(find C:\\\\git\\\\php-json-schema-model-generator:*)", "Bash(tail:*)", "Bash(cd:*)", "WebFetch(domain:api.github.com)", "Bash(head:*)", "Bash(grep:*)", "WebFetch(domain:wiki.php.net)", - "Bash(cd /c/git/php-json-schema-model-generator && ./vendor/bin/phpunit 2>&1)", "Bash(./vendor/bin/phpunit:*)", "Bash(php:*)", "Bash(cat:*)", @@ -28,10 +25,18 @@ "Bash(gh api:*)", "Bash(git restore:*)", "Bash(git stash:*)", + "Bash(git status:*)", "Bash(composer show:*)", "Bash(php -r \":*)", "Bash(gh pr:*)", - "Bash(xargs wc:*)" + "Bash(xargs wc:*)", + "Bash(tee /tmp/phpunit-output.txt)", + "Read(//tmp/**)", + "WebFetch(domain:json-schema.org)", + "WebFetch(domain:datatracker.ietf.org)", + "Bash(for f:*)", + "Bash(do)", + "Bash(done)" ] } } diff --git a/.claude/topics/issue-101/analysis.md b/.claude/topics/issue-101/analysis.md deleted file mode 100644 index 38773375..00000000 --- a/.claude/topics/issue-101/analysis.md +++ /dev/null @@ -1,211 +0,0 @@ -# Issue #101 — TypeError: `_getModifiedValues_*` receives object instead of array - -## Bug description - -When a **nested object property** has a `oneOf`/`anyOf` composition whose branches each carry -**distinct named properties** (so a real `_Merged_` auxiliary class is created), instantiating the -parent class with valid data throws a PHP `TypeError` at runtime: - -``` -TypeError: SomeClass::_getModifiedValues_xxxxx(): Argument #1 ($originalModelData) - must be of type array, SomeNestedClass given -``` - -## Root cause - -### Execution order in the generated `processXxx()` / `validateXxx()` pair - -For a nested object property the generated code looks like this (simplified): - -```php -// processBudgetRange — generated from Model.phptpl line 168–181 -protected function processBudgetRange(array $modelData): void -{ - $value = $modelData['budget_range'] ?? null; - - // ObjectInstantiationDecorator runs HERE (resolvePropertyDecorator with nestedProperty=true) - // $value is now an instance of Budget_range (the nested class), NOT the raw array any more - $value = (function ($value) { - return is_array($value) ? new Budget_range($value) : $value; - })($value); - - $this->budgetRange = $this->validateBudgetRange($value, $modelData); -} - -protected function validateBudgetRange($value, array $modelData) -{ - // ... type / instanceof validators ... - // then the ComposedItem.phptpl closure: - (function (&$value) use (...) { - $originalModelData = $value; // <-- $value is already a Budget_range OBJECT here! - ... - // later, inside the branch loop: - $modifiedValues = array_merge( - $modifiedValues, - $this->_getModifiedValues_xxxxx($originalModelData, $value) - // ^^^^^^^^^^^^^^^^^^ - // Budget_range object passed where array is expected --> TypeError - ); - })($value); -} -``` - -The generated `_getModifiedValues_*` method (in `ComposedPropertyValidator::getCheck()`, -`SchemaProcessor.php` line ~84) has the signature: - -```php -private function _getModifiedValues_xxxxx(array $originalModelData, object $nestedCompositionObject): array -``` - -So passing the already-instantiated object as `$originalModelData` violates the `array` type -declaration and produces the `TypeError`. - -### Why the instantiation happens before validation - -`ObjectInstantiationDecorator` is attached as a **property decorator** (via -`ObjectProcessor::process()`, not as a validator). In `Model.phptpl`, decorators are applied -inside `processXxx()` at the `resolvePropertyDecorator(property, true)` call — **before** the -`validateXxx()` call. By design, the nested object is fully instantiated before validation runs so -that validators can operate on the typed object. - -The composition validator in `ComposedItem.phptpl` then captures that already-instantiated object -as `$originalModelData`. The `_getModifiedValues_*` method expects the **raw** input array in -order to detect which keys were modified by filters inside the branch, but it receives the -already-constructed object instead. - -### When is the merged-property path taken? - -`createMergedProperty` (and therefore the `_getModifiedValues_*` method) is only generated when: -1. `rootLevelComposition` is `false` (the composition is on a nested property, not the root - schema), AND -2. `MergedComposedPropertiesInterface` is implemented (i.e. `anyOf` or `oneOf`), AND -3. `redirectMergedProperty` returns `false` — meaning **multiple** composition branches each have - a nested schema (distinct properties per branch). - -Condition 3 is key: the issue from #98 involved branches that added only constraints (no distinct -properties), so only one branch had a nested schema and `redirectMergedProperty` redirected to it. -Issue #101 involves branches where **each branch has its own set of named properties**, so all -branches have nested schemas and a true `_Merged_` class must be created. - -## Minimal reproduction schema - -A parent object with a nested `pricing_options` array whose items use `oneOf` with two branches -that each have distinct property names: - -```json -{ - "type": "object", - "properties": { - "item": { - "type": "object", - "oneOf": [ - { - "properties": { - "price_per_unit": { "type": "number" } - } - }, - { - "properties": { - "flat_fee": { "type": "number" } - } - } - ] - } - } -} -``` - -Instantiating with `new ParentClass(['item' => ['price_per_unit' => 9.99]])` throws: -``` -TypeError: ParentClass::_getModifiedValues_xxxxx(): Argument #1 ($originalModelData) - must be of type array, given -``` - -The `anyOf` variant produces the same error. - -## Fix options - -### Option A — Pass raw array to `_getModifiedValues_*` from the template - -In `ComposedItem.phptpl`, the `$originalModelData` at line 13 is captured from `$value` which is -already an object. The fix is to capture the raw input array separately before any object -instantiation could have happened, and pass that as the first argument. - -However the ComposedItem template runs **inside** the validator closure, where the outer `$value` -is already the instantiated object. There is no separate "raw array" variable in scope at that -point. - -A possible approach: add a new template variable that holds the raw array representation of the -value. But this requires passing it in from the outer `processXxx` / `validateXxx` scope. - -### Option B (recommended) — Change `_getModifiedValues_*` to accept object|array - -The cleanest fix: make the generated `_getModifiedValues_*` method accept `object | array` instead -of requiring `array`. When the argument is an object, use the object's own accessor methods (or -`get_object_vars`) to extract the raw key-value pairs, mirroring what the array path does. - -Or more simply: when `$originalModelData` is an object, treat all keys as "not originally -present", which causes the method to return `[]` (no modified values). This is correct because -object instantiation already happened; the purpose of `_getModifiedValues_*` is to propagate -filter-transformed values from a branch object back into the merged object. When the outer value -is already an object, it has already gone through any filter transforms during its own -construction, so there is nothing extra to propagate. - -### Option C — Skip `_getModifiedValues_*` call when value is already an object - -In `ComposedItem.phptpl`, the call at line 91–93 is already wrapped in `if (is_object($value))`. -But `$originalModelData` is also an object at that point. A simpler guard: - -``` -if (is_object($value) && is_array($originalModelData)) { - $modifiedValues = array_merge($modifiedValues, $this->{{ modifiedValuesMethod }}($originalModelData, $value)); -} -``` - -When `$originalModelData` is already an object (because the outer property value was instantiated -before the composition validator ran), skip the modified-values collection entirely. The -already-instantiated object already incorporates any filter transforms; there is nothing further to -merge. - -This is the minimal targeted fix and aligns with the existing `is_object($value)` guard's intent. - -## Preferred fix - -**Option C** — add `&& is_array($originalModelData)` to the existing guard in `ComposedItem.phptpl`. -It is a one-line change, surgical, and correct: when the value was already instantiated as an -object before the composition validator ran (the nested-object case), filter-transformed values -have already been applied during construction. There is no raw array to diff against. - -## Key code locations - -- `src/Templates/Validator/ComposedItem.phptpl:91–93` — guard around `_getModifiedValues_*` call -- `src/Model/Validator/ComposedPropertyValidator.php:84` — generated method signature `(array $originalModelData, ...)` -- `src/PropertyProcessor/Property/ObjectProcessor.php:69–77` — where `ObjectInstantiationDecorator` is added -- `src/Templates/Model.phptpl:178` — where property decorator runs (before `validateXxx`) - -## Test coverage plan — COMPLETED - -Schemas: -- `tests/Schema/Issues/101/nestedObjectWithAnyOfDistinctBranchProperties.json` (mode/speed/level) -- `tests/Schema/Issues/101/nestedObjectWithOneOfDistinctProperties.json` (pricing_option) -- `tests/Schema/Issues/101/nestedObjectWithAnyOfDistinctProperties.json` (budget) - -All edge cases covered in `tests/Issues/Issue/Issue101Test.php`: -1. `anyOf` — two branches with distinct named properties → valid input accepted, correct getters work -2. `oneOf` — same scenario, invalid input (both branches) rejected -3. Valid input accepted (correct branch satisfies the composition) -4. Invalid input rejected (neither/both branches match for `oneOf`, no branch for `anyOf`) -5. The returned object is not null and has correct property values -6. Absent/null optional property accepted for all three schemas - -## Resolution - -The issue-#98 fix (`addComposedValueValidator` skipping composition validation for nested object -properties that already have a resolved schema) also resolves issue #101. When `rootLevelComposition` -is false AND the property already has a nested schema, the composition validator is never added to -the parent class, so `_getModifiedValues_*` is never called with an already-instantiated object. - -The `ComposedItem.phptpl` fix (Option C) was the original preferred fix before #98 was in place, -but it is not needed — the #98 guard prevents the problematic path from being reached entirely. - -All 23 tests pass. diff --git a/CLAUDE.md b/CLAUDE.md index ce96adc2..75a7465b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,10 @@ user to resolve it. Rules: -- Ask all foreseeable clarifying questions upfront in a single batch before work begins. +- When there are multiple clarifying questions to ask, ask them **one at a time**, in order of + dependency (earlier answers may resolve later questions). Wait for the answer before asking the + next question. This allows the user to discuss each point in depth without being overwhelmed by + a wall of questions. - If new ambiguities emerge during execution that were not foreseeable upfront, pause and ask follow-up questions before proceeding past that decision point. - For high-stakes decisions (architecture, scope, data model, API shape, behaviour changes) always @@ -19,6 +22,9 @@ Rules: visible so the user can correct it. - There must be no silent interpretation or interpolation of under-specified tasks. If something is unclear, ask. Do not guess and proceed. +- For multi-phase implementations, **never start the next phase without an explicit go-ahead from + the user**. After completing a phase, summarise what was done and wait for confirmation before + proceeding. When generating a new CLAUDE.md for a repository, include this clarification policy verbatim as a preamble before all other content. @@ -48,6 +54,20 @@ composer update Tests write generated PHP classes to `sys_get_temp_dir()/PHPModelGeneratorTest/Models/` and dump failed classes to `./failed-classes/` (auto-cleaned on bootstrap). +### Running the full test suite + +When running the full test suite, always save output to a file so the complete +output is available for analysis without re-running. Use `--display-warnings` to capture warning +details and `--no-coverage` to skip slow coverage collection: + +```bash +php -d memory_limit=128M ./vendor/bin/phpunit --no-coverage --display-warnings 2>&1 | sed 's/\x1b\[[0-9;]*m//g' > /tmp/phpunit-output.txt; tail -5 /tmp/phpunit-output.txt +``` + +Then analyse with: `grep -E "FAIL|ERROR|WARN|Tests:" /tmp/phpunit-output.txt` + +After analysis is complete, delete the file: `rm /tmp/phpunit-output.txt` + ## Architecture This library generates PHP model classes from JSON Schema files. The process is a 4-step pipeline: @@ -72,15 +92,25 @@ This library generates PHP model classes from JSON Schema files. The process is ### Schema Processing `SchemaProcessor` (`src/SchemaProcessor/SchemaProcessor.php`) orchestrates property parsing: -- Uses `PropertyProcessorFactory` to instantiate the correct processor by JSON type (String, Integer, Number, Boolean, Array, Object, Null, Const, Any, Reference) -- Convention: processor class name is `PHPModelGenerator\PropertyProcessor\Property\{Type}Processor` +- Uses `PropertyFactory` (`src/PropertyProcessor/PropertyFactory.php`) to create and configure each property +- `PropertyFactory` resolves `$ref` references, delegates `object` types to `processSchema`, and for all other types constructs a `Property` directly and applies Draft modifiers - `ComposedValueProcessorFactory` handles `allOf`, `anyOf`, `oneOf`, `if/then/else`, `not` - `SchemaDefinitionDictionary` tracks `$ref` definitions to avoid duplicate processing +### Draft System (`src/Draft/`) + +The Draft system defines per-type modifier and validator registrations: +- **`DraftInterface`** / **`DraftBuilder`** / **`Draft`** — Draft definition, builder, and built (immutable) registry +- **`Draft_07.php`** — The JSON Schema Draft 7 definition; registers all types, modifiers, and validator factories +- **`Element/Type`** — One entry per JSON Schema type; holds an ordered list of `ModifierInterface` instances +- **`Modifier/`** — `TypeCheckModifier`, `ConstModifier`, `NumberModifier`, `NullModifier`, `ObjectType/ObjectModifier`, `DefaultValueModifier`, `DefaultArrayToEmptyArrayModifier`; each implements `ModifierInterface::modify()` +- **`Model/Validator/Factory/`** — `AbstractValidatorFactory` subclasses keyed to schema keywords (e.g. `MinLengthPropertyValidatorFactory` for `minLength`); run as modifiers when a matching key exists in the schema + +`PropertyFactory::applyDraftModifiers` resolves `getCoveredTypes($type)` (which always includes `'any'`) and runs every modifier for each covered type in order. + ### Property Processors (`src/PropertyProcessor/`) -- `Property/` — One processor per JSON Schema type -- `ComposedValue/` — Processors for composition keywords +- `ComposedValue/` — Processors for composition keywords (`allOf`, `anyOf`, `oneOf`, `if/then/else`, `not`) - `Filter/` — Custom filter processing - `Decorator/` — Property decorators (ObjectInstantiation, PropertyTransfer, IntToFloatCast, etc.) @@ -104,6 +134,17 @@ Implement `SchemaProviderInterface` to supply schemas from custom sources. Built `AbstractPHPModelGeneratorTestCase` is the base class for all tests. Tests generate model classes into a temp directory and then instantiate/exercise them to verify validation behavior. The `tests/manual/` directory contains standalone scripts excluded from the test suite. +#### Test case consolidation + +Each call to `generateClassFromFile` triggers a code generation pass, which is the dominant cost in the test suite. **Minimise the number of distinct `generateClassFromFile` calls** by combining assertions that share the same schema file and `GeneratorConfiguration` into a single test method. + +Rules: + +- Group assertions by `(schema file, GeneratorConfiguration)` pair. All assertions that can use the same generated class belong in one test method. +- A single test method may cover multiple behaviours (e.g. key naming, round-trip, `$except`, custom serializer) as long as they all operate on the same generated class. Use clear inline comments to separate the logical sections. +- Only split into separate test methods when the behaviours require genuinely different configurations, or when combining them would make the test too complex to understand at a glance. +- The goal is the balance between runtime efficiency (fewer generations) and readability (each method remains comprehensible). Avoid both extremes: a single monolithic test and a proliferation of single-assertion tests. + ### JSON Schema style In test schema files (`tests/Schema/**/*.json`), every object value must be expanded across multiple lines — never written inline. Use this style: @@ -131,6 +172,33 @@ property, duplicate property names with unresolvable type conflicts, and any oth that cannot produce a correct PHP model. Fail loudly at generation time so the developer sees the problem immediately rather than receiving silently incorrect generated code. +### Filter callable classes must be in the production library + +A `FilterInterface::getFilter()` callable is embedded verbatim in generated PHP code and is +called at runtime — without the generator package being present. Any class referenced in +`getFilter()` must therefore live in `php-json-schema-model-generator-production`, not in this +generator package. Using a generator-package class as a filter callable will produce generated +code that fails at runtime whenever the generator is not installed. + +If a production-library class lacks the required type hints (needed for reflection-based type +derivation), the fix is to add or update the callable in the production library, not to create +a wrapper class here. + +### Staging changes + +After finishing an implementation task, always stage all relevant changed files for commit using +`git add`. Do not wait for the user to ask — stage immediately when the work is done. + +### 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. + +### Variable naming + +Never use single-character variable names. All variables must have meaningful, descriptive names +that convey their purpose. For example, use `$typeName` instead of `$t`, `$validator` instead of +`$v`, `$property` instead of `$p`. + ### PHP import style Always add `use` imports for every class referenced in a file, including global PHP classes such as diff --git a/composer.json b/composer.json index 908862e0..f77d2891 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ } ], "require": { - "wol-soft/php-json-schema-model-generator-production": "^0.21.1", + "wol-soft/php-json-schema-model-generator-production": "dev-master", "wol-soft/php-micro-template": "^1.10.2", "php": ">=8.4", diff --git a/docs/requirements.txt b/docs/requirements.txt index 9d515cb3..7497fd90 100644 Binary files a/docs/requirements.txt and b/docs/requirements.txt differ diff --git a/docs/source/conf.py b/docs/source/conf.py index 098ac05f..f5c84852 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -58,7 +58,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. diff --git a/docs/source/generator/custom/customDraft.rst b/docs/source/generator/custom/customDraft.rst new file mode 100644 index 00000000..1731bd96 --- /dev/null +++ b/docs/source/generator/custom/customDraft.rst @@ -0,0 +1,126 @@ +Custom Drafts +============= + +The *Draft* system defines which JSON Schema keywords are recognised during model generation and +how each keyword is translated into PHP code. Every ``GeneratorConfiguration`` has a +*draft* — by default ``AutoDetectionDraft``, which inspects the ``$schema`` keyword in each +schema file and picks the appropriate draft automatically. + +You can replace the default draft with a concrete ``DraftInterface`` implementation to pin all +schemas to a single set of rules, or supply a ``DraftFactoryInterface`` implementation to choose +the draft dynamically per schema file. + +Concepts +-------- + +Type + A named JSON Schema type (``"object"``, ``"string"``, ``"integer"``, ``"number"``, + ``"boolean"``, ``"array"``, ``"null"``, or the virtual ``"any"`` type that applies to every + property regardless of its declared type). + + Each type entry holds an ordered list of *modifiers*. + +Modifier (``ModifierInterface``) + A unit of work that reads the raw JSON Schema for a property and modifies the in-memory + ``PropertyInterface`` object — adding validators, decorators, type hints, or any other + enrichment. Modifiers are executed in registration order, and the ``"any"`` modifiers run + for every property. + +Validator factory (``AbstractValidatorFactory``) + A special modifier that is keyed to a single JSON Schema keyword (e.g. ``"minLength"``). + It checks whether that keyword is present in the schema and, if so, adds the corresponding + validator to the property. Validator factories are registered via ``Type::addValidator()``. + +Implementing a custom draft +--------------------------- + +The simplest approach is to extend an existing draft (e.g. ``Draft_07``) via the builder API and +override the parts you need. + +**Example: add a custom keyword modifier to Draft 7** + +.. code-block:: php + + use PHPModelGenerator\Draft\Draft_07; + use PHPModelGenerator\Draft\DraftBuilder; + use PHPModelGenerator\Draft\DraftInterface; + use PHPModelGenerator\Draft\Modifier\ModifierInterface; + use PHPModelGenerator\Model\Property\PropertyInterface; + use PHPModelGenerator\Model\Schema; + use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; + use PHPModelGenerator\SchemaProcessor\SchemaProcessor; + + class DeprecatedModifier implements ModifierInterface + { + public function modify( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + if (!($propertySchema->getJson()['deprecated'] ?? false)) { + return; + } + + // Add a PHPDoc annotation or any other enrichment here. + } + } + + class MyDraft implements DraftInterface + { + public function getDefinition(): DraftBuilder + { + // Obtain the standard Draft 7 builder and append a modifier to the 'any' type. + $builder = (new Draft_07())->getDefinition(); + $builder->getType('any')->addModifier(new DeprecatedModifier()); + + return $builder; + } + } + +Register the custom draft in your generator configuration: + +.. code-block:: php + + use PHPModelGenerator\Model\GeneratorConfiguration; + + $configuration = (new GeneratorConfiguration()) + ->setDraft(new MyDraft()); + +Implementing a draft factory +----------------------------- + +If you need to select the draft dynamically per schema file, implement +``DraftFactoryInterface``: + +.. code-block:: php + + use PHPModelGenerator\Draft\AutoDetectionDraft; + use PHPModelGenerator\Draft\DraftFactoryInterface; + use PHPModelGenerator\Draft\DraftInterface; + use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; + + class MyDraftFactory implements DraftFactoryInterface + { + private AutoDetectionDraft $autoDetect; + private MyDraft $myDraft; + + public function __construct() + { + $this->autoDetect = new AutoDetectionDraft(); + $this->myDraft = new MyDraft(); + } + + public function getDraftForSchema(JsonSchema $schema): DraftInterface + { + // Use the custom draft only for schemas that opt in via a custom flag. + if ($schema->getJson()['x-use-my-draft'] ?? false) { + return $this->myDraft; + } + + return $this->autoDetect->getDraftForSchema($schema); + } + } + + $configuration = (new GeneratorConfiguration()) + ->setDraft(new MyDraftFactory()); diff --git a/docs/source/generator/custom/customPostProcessor.rst b/docs/source/generator/custom/customPostProcessor.rst index ca8d967d..4406d799 100644 --- a/docs/source/generator/custom/customPostProcessor.rst +++ b/docs/source/generator/custom/customPostProcessor.rst @@ -49,13 +49,13 @@ To execute code before/after the processing of the schemas override the methods PHP Attributes -------------- -You can attach PHP 8.0 `attributes `__ to the generated class and to individual properties using **PHPModelGenerator\\Model\\PhpAttribute**. +You can attach PHP 8.0 `attributes `__ to the generated class and to individual properties using **PHPModelGenerator\\Model\\Attributes\\PhpAttribute**. **Class-level attribute** (placed before the ``class`` keyword in the generated file): .. code-block:: php - use PHPModelGenerator\Model\PhpAttribute; + use PHPModelGenerator\Model\Attributes\PhpAttribute; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; class ORM_EntityPostProcessor extends PostProcessor @@ -80,7 +80,7 @@ You can attach PHP 8.0 `attributes `__ on the **GeneratorConfiguration** diff --git a/docs/source/generator/postProcessor.rst b/docs/source/generator/postProcessor.rst index 2ed64b35..6a045a05 100644 --- a/docs/source/generator/postProcessor.rst +++ b/docs/source/generator/postProcessor.rst @@ -24,7 +24,8 @@ All added post processors will be executed after a schema was processed and befo builtin/patternPropertiesAccessorPostProcessor .. toctree:: - :caption: Custom Post Processors + :caption: Custom Extensions :maxdepth: 1 custom/customPostProcessor + custom/customDraft diff --git a/docs/source/gettingStarted.rst b/docs/source/gettingStarted.rst index 41b50361..4d0d7ba0 100644 --- a/docs/source/gettingStarted.rst +++ b/docs/source/gettingStarted.rst @@ -280,10 +280,81 @@ The generated class will implement the interface **PHPModelGenerator\\Interfaces Additionally the class will implement the PHP builtin interface **\JsonSerializable** which allows the direct usage of the generated classes in a custom json_encode. +Output keys +""""""""""" + +The keys in the serialized output are the **original JSON Schema property names**, not the PHP property names. For example, a schema property named ``product_id`` will appear as ``product_id`` in the output, even though the generated PHP property is named ``$productId``. This ensures that the output of ``toArray()`` / ``toJSON()`` can be fed back into the same class constructor without validation errors (round-trip). + +The ``$except`` parameter must also contain **schema names** (not PHP property names). Passing the PHP-normalized form will not exclude the property. + +If a generated model contains nested objects that also have serialization enabled, the ``$depth`` budget is now correctly shared across all nesting levels. A ``$depth`` of 1 serializes only the top-level properties; nested objects are replaced with ``null`` when the budget is exhausted. + .. warning:: If you provide `additional properties `__ you may want to use the `AdditionalPropertiesAccessorPostProcessor `__ as the additional properties by default aren't included into the serialization result. +Attributes +^^^^^^^^^^ + +By default, the generator adds a predefined set of attributes to the generated classes and their properties. +To control, which attributes are generated, the **GeneratorConfiguration** class provides the following methods: + +.. code-block:: php + + // overwrite all defined attributes + setEnabledAttributes(int $enabledAttributes); + // enable a specific set of attributes + enableAttributes(int $attributes); + // disable a specific set of attributes + disableAttributes(int $attributes); + +The following attributes are available: + +.. list-table:: + :header-rows: 1 + + * - Attribute + - Target + - Description + - Enabled by default + * - JSON_POINTER + - Classes & Properties + - Adds a JSON Pointer with the path in the source Schema. This attribute can't be disabled. + - Yes + * - SCHEMA_NAME + - Properties + - Provides the original JSON Schema name of the property. This attribute can't be disabled. + - Yes + * - SOURCE + - Classes + - Provides the source file which contains the schema used for generation + - No + * - JSON_SCHEMA + - Classes & Properties + - Provides the full JSON Schema used to generate the class or the property + - No + * - REQUIRED + - Properties + - Set for required properties + - Yes + * - READ_WRITE_ONLY + - Properties + - Sets the *ReadOnlyProperty* and *WriteOnlyProperty* attribute depending on the corresponding JSON schema flag + - Yes + * - DEPRECATED + - Classes & Properties + - If deprecated is set to true in the JSON schema, the *Deprecated* attribute gets added + - Yes + +The following example would keep the *SCHEMA_NAME* and *JSON_SCHEMA* attributes enabled: + +.. code-block:: php + + $configuration = (new GeneratorConfiguration()) + ->setEnabledAttributes(PhpAttribute::JSON_POINTER | PhpAttribute::SCHEMA_NAME) + ->enableAttributes(PhpAttribute::SOURCE | PhpAttribute::JSON_SCHEMA) + ->disableAttributes(PhpAttribute::JSON_POINTER | PhpAttribute::SOURCE); + Output generation process ^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -309,6 +380,34 @@ The output of a generation process may look like: Rendered class MyApp\User\Response\Login Rendered class MyApp\User\Response\Register +JSON Schema draft version +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: php + + setDraft(DraftInterface|DraftFactoryInterface $draft); + +Controls which JSON Schema draft version is used during generation. Accepts either a concrete +draft instance (``DraftInterface``) to pin all schemas to one draft, or a factory +(``DraftFactoryInterface``) to select the draft per schema file. + +By default ``AutoDetectionDraft`` is used. It implements ``DraftFactoryInterface`` and inspects +the ``$schema`` keyword of each schema file to select the appropriate draft automatically. When +the keyword is absent or unrecognised, it falls back to JSON Schema Draft 7 behaviour, so schemas +with different ``$schema`` declarations in the same generation run can use different drafts. + +Available draft classes: + +============= ================================ +Draft class Description +============= ================================ +``Draft_07`` JSON Schema Draft 7 (default) +============= ================================ + +.. seealso:: + + :doc:`generator/custom/customDraft` — how to implement a custom draft or modifier. + Custom filter ^^^^^^^^^^^^^ diff --git a/docs/source/nonStandardExtensions/filter.rst b/docs/source/nonStandardExtensions/filter.rst index 2c3a4fc5..4093b903 100644 --- a/docs/source/nonStandardExtensions/filter.rst +++ b/docs/source/nonStandardExtensions/filter.rst @@ -1,7 +1,8 @@ Filter ====== -Filter can be used to preprocess values. Filters are applied after the required and type validation. If a filter is applied to a property which has a type which is not supported by the filter an IncompatibleFilterException will be thrown. +Filter can be used to preprocess values. Filters are applied after the required and type validation. +If the property type and the filter's accepted types have **no overlap at all**, a ``SchemaException`` is thrown at generation time. When there is only a **partial overlap**, the filter is silently skipped at runtime for values whose type is not in the accepted set — the value passes through unchanged. Filters can be either supplied as a string or as a list of filters (multiple filters can be applied to a single property): .. code-block:: json @@ -97,23 +98,12 @@ If you write a custom transforming filter you must define the return type of you The return type of the transforming filter will be used to define the type of the property inside the generated model (in the example one section above given above the method **getCreated** will return a DateTime object). Additionally the generated model also accepts the transformed type as input type. So **setCreated** will accept a string and a DateTime object. If an already transformed value is provided the filter which transforms the value will **not** be executed. Also all filters which are defined before the transformation will **not** be executed (eg. a trim filter before a dateTime filter will not be executed if a DateTime object is provided). -If you use a filter on a property which accepts multiple types (eg. explicit null ['string', 'null'] or ['string', 'integer']) the filter must accept each of the types defined on the property. +If you use a filter on a property which accepts multiple types (e.g. ``['string', 'null']`` or ``['string', 'integer']``), the filter only needs to overlap with **at least one** of those types. Values whose type is not accepted by the filter are silently skipped at runtime. Only if the filter's accepted types have *no overlap at all* with the property's types is a ``SchemaException`` raised at generation time. Exceptions from filter ---------------------- -If a filter is called with a type which isn't supported by the filter a *PHPModelGenerator\\Exception\\ValidationException\\IncompatibleFilterException* will be thrown which provides the following methods to get further error details: - -.. code-block:: php - - // returns the token of the filter which wasn't able to be processed - public function getFilterToken(): string - // get the name of the property which failed - public function getPropertyName(): string - // get the value provided to the property - public function getProvidedValue() - -If the filter throws an exception during the execution the exception will be caught and converted into a *PHPModelGenerator\\Exception\\Filter\\InvalidFilterValueException* which provides the following methods to get further error details: +If the filter throws an exception during execution the exception will be caught and converted into a *PHPModelGenerator\\Exception\\Filter\\InvalidFilterValueException* which provides the following methods to get further error details: .. code-block:: php @@ -132,7 +122,7 @@ Builtin filter trim ^^^^ -The trim filter is only valid for string and null properties. +The trim filter accepts string and null values. Applied to a property that also allows other types (e.g. ``string|integer``), the filter is silently skipped for values of non-matching types and the value passes through unchanged. Only applying trim to a property whose type has *no overlap* with string or null (e.g. a pure integer property) raises an error at generation time. .. code-block:: json @@ -169,7 +159,7 @@ Let's have a look how the generated model behaves: // MinLengthException: 'Value for name must not be shorter than 2' $person->setName(' D '); -If the filter trim is used for a property which doesn't require a string value and a non string value is provided an exception will be thrown: +If trim is applied to a property whose type has zero overlap with string or null (e.g. a pure boolean property), a ``SchemaException`` is raised at generation time: * Filter trim is not compatible with property type __TYPE__ for property __PROPERTY_NAME__ @@ -290,7 +280,9 @@ You can implement custom filter and use them in your schema files. You must add ); Your filter must implement the interface **PHPModelGenerator\\Filter\\FilterInterface**. Make sure the given callable array returned by **getFilter** is accessible as well during the generation process as during code execution using the generated model. -The callable filter method must be a static method. Internally it will be called via *call_user_func_array*. A custom filter may look like: +The callable filter method must be a static method. Internally it will be called via *call_user_func_array*. + +The accepted value types are derived automatically from the **type hint of the first parameter** of the filter callable via reflection. Use a union type (``string|int``), a nullable type (``?string``), or ``mixed`` to express which types the filter handles. **Every filter callable must declare a type hint on its first parameter** — omitting it raises an ``InvalidFilterException`` at generation time. Using ``mixed`` signals that the filter accepts all types (no runtime type guard is generated). A custom filter may look like: .. code-block:: php @@ -300,20 +292,13 @@ The callable filter method must be a static method. Internally it will be called class UppercaseFilter implements FilterInterface { + // The ?string type hint tells the generator that this filter handles + // string and null values. Integer, float, etc. are silently skipped. public static function uppercase(?string $value): ?string { - // we want to handle strings and null values with this filter return $value !== null ? strtoupper($value) : null; } - public function getAcceptedTypes(): array - { - // return an array of types which can be handled by the filter. - // valid types are: [integer, number, boolean, string, array, null] - // or available classes (FQCN required, eg. DateTime::class) - return ['string', 'null']; - } - public function getToken(): string { return 'uppercase'; @@ -325,10 +310,6 @@ The callable filter method must be a static method. Internally it will be called } } -.. hint:: - - If your filter accepts null values add 'null' to your *getAcceptedTypes* to make sure your filter is compatible with explicit null type. - .. hint:: If a filter with the token of your custom filter already exists the existing filter will be overwritten when adding the filter to the generator configuration. By overwriting filters you may change the behaviour of builtin filters by replacing them with your custom implementation. @@ -425,7 +406,8 @@ The custom serializer method will be called if the model utilizing the custom fi class CustomerFilter implements TransformingFilterInterface { // Let's assume you have written a Customer model manually eg. due to advanced validations - // and you want to use the Customer model as a part of your generated model + // and you want to use the Customer model as a part of your generated model. + // The ?array type hint tells the generator that this filter accepts array and null values. public static function instantiateCustomer(?array $data, array $additionalOptions): ?Customer { return $data !== null ? new Customer($data, $additionalOptions) : null; @@ -435,12 +417,7 @@ The custom serializer method will be called if the model utilizing the custom fi // $additionalOptions will contain all additional options from the JSON Schema public static function serializeCustomer(?Customer $customer, array $additionalOptions): ?string { - return $data !== null ? $customer->serialize($additionalOptions) : null; - } - - public function getAcceptedTypes(): array - { - return ['string', 'null']; + return $customer !== null ? $customer->serialize($additionalOptions) : null; } public function getToken(): string diff --git a/docs/source/toc-generator.rst b/docs/source/toc-generator.rst index 7ce90705..b7d34c6c 100644 --- a/docs/source/toc-generator.rst +++ b/docs/source/toc-generator.rst @@ -3,3 +3,4 @@ :maxdepth: 2 generator/postProcessor + generator/custom/customDraft diff --git a/phpcs.xml b/phpcs.xml index e2912910..83f5f22f 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -12,5 +12,7 @@ + + diff --git a/src/Draft/AutoDetectionDraft.php b/src/Draft/AutoDetectionDraft.php new file mode 100644 index 00000000..edcc8681 --- /dev/null +++ b/src/Draft/AutoDetectionDraft.php @@ -0,0 +1,21 @@ +draftInstances[Draft_07::class] ??= new Draft_07(); + } +} diff --git a/src/Draft/Draft.php b/src/Draft/Draft.php new file mode 100644 index 00000000..7cde1d58 --- /dev/null +++ b/src/Draft/Draft.php @@ -0,0 +1,67 @@ +types; + } + + public function hasType(string $type): bool + { + return isset($this->types[$type]); + } + + /** + * Returns the Type entries whose modifiers apply to a property of the given type(s). + * The special type 'any' always applies to every property; passing 'any' returns all types. + * + * @param string|string[] $type + * + * @return Type[] + * + * @throws SchemaException + */ + public function getCoveredTypes(string | array $type): array + { + if (!is_array($type)) { + $type = [$type]; + } + + if (in_array('any', $type, true)) { + return $this->types; + } + + // 'any' modifiers always apply regardless of the concrete type + $type[] = 'any'; + + $unknownTypes = array_diff($type, array_keys($this->types)); + if ($unknownTypes) { + throw new SchemaException(sprintf( + 'Unsupported property type %s', + count($unknownTypes) === 1 + ? reset($unknownTypes) + : '[' . implode(',', $unknownTypes) . ']', + )); + } + + return array_intersect_key($this->types, array_fill_keys($type, null)); + } +} diff --git a/src/Draft/DraftBuilder.php b/src/Draft/DraftBuilder.php new file mode 100644 index 00000000..d8880751 --- /dev/null +++ b/src/Draft/DraftBuilder.php @@ -0,0 +1,30 @@ +types[$type->getType()] = $type; + + return $this; + } + + public function getType(string $type): ?Type + { + return $this->types[$type] ?? null; + } + + public function build(): Draft + { + return new Draft($this->types); + } +} diff --git a/src/Draft/DraftFactoryInterface.php b/src/Draft/DraftFactoryInterface.php new file mode 100644 index 00000000..4db9e02b --- /dev/null +++ b/src/Draft/DraftFactoryInterface.php @@ -0,0 +1,12 @@ +addType((new Type('object', false)) + ->addValidator('properties', new PropertiesValidatorFactory()) + ->addValidator('propertyNames', new PropertyNamesValidatorFactory()) + ->addValidator('patternProperties', new PatternPropertiesValidatorFactory()) + ->addValidator('additionalProperties', new AdditionalPropertiesValidatorFactory()) + ->addValidator('minProperties', new MinPropertiesValidatorFactory()) + ->addValidator('maxProperties', new MaxPropertiesValidatorFactory()) + ->addModifier(new ObjectModifier())) + ->addType((new Type('array')) + ->addValidator('items', new ItemsValidatorFactory()) + ->addValidator('minItems', new MinItemsValidatorFactory()) + ->addValidator('maxItems', new MaxItemsValidatorFactory()) + ->addValidator('uniqueItems', new UniqueItemsValidatorFactory()) + ->addValidator('contains', new ContainsValidatorFactory()) + ->addModifier(new DefaultArrayToEmptyArrayModifier())) + ->addType((new Type('string')) + ->addValidator('pattern', new PatternPropertyValidatorFactory()) + ->addValidator('minLength', new MinLengthPropertyValidatorFactory()) + ->addValidator('maxLength', new MaxLengthValidatorFactory()) + ->addValidator('format', new FormatValidatorFactory())) + ->addType((new Type('integer')) + ->addValidator('minimum', new MinimumValidatorFactory('is_int')) + ->addValidator('maximum', new MaximumValidatorFactory('is_int')) + ->addValidator('exclusiveMinimum', new ExclusiveMinimumValidatorFactory('is_int')) + ->addValidator('exclusiveMaximum', new ExclusiveMaximumValidatorFactory('is_int')) + ->addValidator('multipleOf', new MultipleOfPropertyValidatorFactory('is_int', true))) + ->addType((new Type('number')) + ->addValidator('minimum', new MinimumValidatorFactory('is_float')) + ->addValidator('maximum', new MaximumValidatorFactory('is_float')) + ->addValidator('exclusiveMinimum', new ExclusiveMinimumValidatorFactory('is_float')) + ->addValidator('exclusiveMaximum', new ExclusiveMaximumValidatorFactory('is_float')) + ->addValidator('multipleOf', new MultipleOfPropertyValidatorFactory('is_float', false)) + ->addModifier(new IntToFloatModifier())) + ->addType(new Type('boolean')) + ->addType((new Type('null')) + ->addModifier(new NullModifier())) + ->addType((new Type('any', false)) + ->addValidator('enum', new EnumValidatorFactory()) + ->addValidator('filter', new FilterValidatorFactory()) + ->addValidator('allOf', new AllOfValidatorFactory()) + ->addValidator('anyOf', new AnyOfValidatorFactory()) + ->addValidator('oneOf', new OneOfValidatorFactory()) + ->addValidator('not', new NotValidatorFactory()) + ->addValidator('if', new IfValidatorFactory()) + ->addModifier(new DefaultValueModifier()) + ->addModifier(new ConstModifier())); + } +} diff --git a/src/Draft/Element/Type.php b/src/Draft/Element/Type.php new file mode 100644 index 00000000..38f2c9d7 --- /dev/null +++ b/src/Draft/Element/Type.php @@ -0,0 +1,51 @@ +modifiers[] = new TypeCheckModifier(TypeConverter::jsonSchemaToPHP($type)); + } + } + + public function addModifier(ModifierInterface $modifier): self + { + $this->modifiers[] = $modifier; + + return $this; + } + + public function addValidator(string $validatorKey, AbstractValidatorFactory $factory): self + { + $factory->setKey($validatorKey); + $this->modifiers[$validatorKey] = $factory; + + return $this; + } + + public function getType(): string + { + return $this->type; + } + + /** + * @return ModifierInterface[] + */ + public function getModifiers(): array + { + return $this->modifiers; + } +} diff --git a/src/PropertyProcessor/Property/ConstProcessor.php b/src/Draft/Modifier/ConstModifier.php similarity index 55% rename from src/PropertyProcessor/Property/ConstProcessor.php rename to src/Draft/Modifier/ConstModifier.php index dbd9b3a2..0c145d65 100644 --- a/src/PropertyProcessor/Property/ConstProcessor.php +++ b/src/Draft/Modifier/ConstModifier.php @@ -2,47 +2,43 @@ declare(strict_types=1); -namespace PHPModelGenerator\PropertyProcessor\Property; +namespace PHPModelGenerator\Draft\Modifier; use PHPModelGenerator\Exception\Generic\InvalidConstException; -use PHPModelGenerator\Model\Property\Property; use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Property\PropertyType; +use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator\PropertyValidator; +use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; use PHPModelGenerator\Utils\TypeConverter; -/** - * Class ConstProcessor - * - * @package PHPModelGenerator\PropertyProcessor\Property - */ -class ConstProcessor extends AbstractPropertyProcessor +class ConstModifier implements ModifierInterface { - /** - * @inheritdoc - */ - public function process(string $propertyName, JsonSchema $propertySchema): PropertyInterface - { + public function modify( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { $json = $propertySchema->getJson(); - $property = new Property( - $propertyName, + if (!array_key_exists('const', $json)) { + return; + } + + $property->setType( new PropertyType(TypeConverter::gettypeToInternal(gettype($json['const']))), - $propertySchema, - $json['description'] ?? '', ); - $property->setRequired($this->propertyMetaDataCollection->isAttributeRequired($propertyName)); - $check = match (true) { $property->isRequired() => '$value !== ' . var_export($json['const'], true), - $this->isImplicitNullAllowed($property) + $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() && !$property->isRequired() => '!in_array($value, ' . RenderHelper::varExportArray([$json['const'], null]) . ', true)', default - => "array_key_exists('{$property->getName()}', \$modelData) && \$value !== " + => "array_key_exists('" . addslashes($property->getName()) . "', \$modelData) && \$value !== " . var_export($json['const'], true), }; @@ -52,9 +48,5 @@ public function process(string $propertyName, JsonSchema $propertySchema): Prope InvalidConstException::class, [$json['const']], )); - - $this->generateValidators($property, $propertySchema); - - return $property; } } diff --git a/src/Draft/Modifier/DefaultArrayToEmptyArrayModifier.php b/src/Draft/Modifier/DefaultArrayToEmptyArrayModifier.php new file mode 100644 index 00000000..160fdd35 --- /dev/null +++ b/src/Draft/Modifier/DefaultArrayToEmptyArrayModifier.php @@ -0,0 +1,42 @@ +isRequired() || + !$schemaProcessor->getGeneratorConfiguration()->isDefaultArraysToEmptyArrayEnabled() + ) { + return; + } + + $property->addDecorator(new DefaultArrayToEmptyArrayDecorator()); + + if ($property->getType()) { + $property->setType( + $property->getType(), + new PropertyType($property->getType(true)->getNames(), false), + ); + } + + if (!$property->getDefaultValue()) { + $property->setDefaultValue([]); + } + } +} diff --git a/src/Draft/Modifier/DefaultValueModifier.php b/src/Draft/Modifier/DefaultValueModifier.php new file mode 100644 index 00000000..a5ccc01c --- /dev/null +++ b/src/Draft/Modifier/DefaultValueModifier.php @@ -0,0 +1,59 @@ +getJson(); + + if (!array_key_exists('default', $json)) { + return; + } + + $default = $json['default']; + $types = isset($json['type']) ? (array) $json['type'] : []; + + if (empty($types)) { + $property->setDefaultValue($default); + return; + } + + foreach ($types as $jsonType) { + $phpType = TypeConverter::jsonSchemaToPHP($jsonType); + + // Allow integer literals as defaults for 'number' (float) properties + if ($phpType === 'float' && is_int($default)) { + $default = (float) $default; + } + + $typeCheckFn = 'is_' . $phpType; + if (function_exists($typeCheckFn) && $typeCheckFn($default)) { + $property->setDefaultValue($default); + return; + } + } + + throw new SchemaException( + sprintf( + 'Invalid type for default value of property %s in file %s', + $property->getName(), + $propertySchema->getFile(), + ), + ); + } +} diff --git a/src/Draft/Modifier/IntToFloatModifier.php b/src/Draft/Modifier/IntToFloatModifier.php new file mode 100644 index 00000000..6b90eee1 --- /dev/null +++ b/src/Draft/Modifier/IntToFloatModifier.php @@ -0,0 +1,23 @@ +addDecorator(new IntToFloatCastDecorator()); + } +} diff --git a/src/Draft/Modifier/ModifierInterface.php b/src/Draft/Modifier/ModifierInterface.php new file mode 100644 index 00000000..e113a013 --- /dev/null +++ b/src/Draft/Modifier/ModifierInterface.php @@ -0,0 +1,20 @@ +setType(null); + $property->addTypeHintDecorator(new TypeHintDecorator(['null'])); + } +} diff --git a/src/Draft/Modifier/ObjectType/ObjectModifier.php b/src/Draft/Modifier/ObjectType/ObjectModifier.php new file mode 100644 index 00000000..8af65d00 --- /dev/null +++ b/src/Draft/Modifier/ObjectType/ObjectModifier.php @@ -0,0 +1,73 @@ +getNestedSchema(); + + if ( + $nestedSchema->getClassPath() !== $schema->getClassPath() || + $nestedSchema->getClassName() !== $schema->getClassName() + ) { + $schema->addUsedClass( + join( + '\\', + array_filter([ + $schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(), + $nestedSchema->getClassPath(), + $nestedSchema->getClassName(), + ]), + ) + ); + + $schema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($nestedSchema)); + } + + $property + ->addDecorator( + new ObjectInstantiationDecorator( + $nestedSchema->getClassName(), + $schemaProcessor->getGeneratorConfiguration(), + ) + ) + ->setType(new PropertyType($nestedSchema->getClassName())); + + $property->addValidator(new InstanceOfValidator($property), 3); + } +} diff --git a/src/Draft/Modifier/TypeCheckModifier.php b/src/Draft/Modifier/TypeCheckModifier.php new file mode 100644 index 00000000..9089d55e --- /dev/null +++ b/src/Draft/Modifier/TypeCheckModifier.php @@ -0,0 +1,43 @@ +getValidators() as $validator) { + if ( + $validator->getValidator() instanceof TypeCheckInterface && + in_array($this->type, $validator->getValidator()->getTypes(), true) + ) { + return; + } + } + + $property->addValidator( + new TypeCheckValidator( + $this->type, + $property, + $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() && !$property->isRequired(), + ), + 2, + ); + } +} diff --git a/src/Model/Attributes/AttributesTrait.php b/src/Model/Attributes/AttributesTrait.php new file mode 100644 index 00000000..f12cf42a --- /dev/null +++ b/src/Model/Attributes/AttributesTrait.php @@ -0,0 +1,38 @@ +getEnabledAttributes() & $enablementFlag) === 0 + ) { + return $this; + } + + $this->phpAttributes[] = $attribute; + + return $this; + } + + /** + * @return PhpAttribute[] + */ + public function getAttributes(): array + { + return $this->phpAttributes; + } +} diff --git a/src/Model/PhpAttribute.php b/src/Model/Attributes/PhpAttribute.php similarity index 69% rename from src/Model/PhpAttribute.php rename to src/Model/Attributes/PhpAttribute.php index 0474984d..5a1d56bc 100644 --- a/src/Model/PhpAttribute.php +++ b/src/Model/Attributes/PhpAttribute.php @@ -2,10 +2,21 @@ declare(strict_types=1); -namespace PHPModelGenerator\Model; +namespace PHPModelGenerator\Model\Attributes; final class PhpAttribute { + public const int JSON_POINTER = 1; + public const int SCHEMA_NAME = 2; + public const int SOURCE = 4; + public const int JSON_SCHEMA = 8; + public const int REQUIRED = 16; + public const int READ_WRITE_ONLY = 32; + public const int DEPRECATED = 64; + + // Attributes which are always enabled because they are used for internal functionality + public const int ALWAYS_ENABLED_ATTRIBUTES = self::JSON_POINTER | self::SCHEMA_NAME; + /** * @param string $fqcn Fully-qualified class name of the attribute. * @param array $arguments Pre-rendered PHP expression strings. @@ -37,6 +48,7 @@ public function render(): string $args = []; foreach ($this->arguments as $key => $value) { + $value = var_export($value, true); $args[] = is_string($key) ? "$key: $value" : $value; } diff --git a/src/Model/AttributesTrait.php b/src/Model/AttributesTrait.php deleted file mode 100644 index cf08e4b3..00000000 --- a/src/Model/AttributesTrait.php +++ /dev/null @@ -1,26 +0,0 @@ -phpAttributes[] = $attribute; - - return $this; - } - - /** - * @return PhpAttribute[] - */ - public function getAttributes(): array - { - return $this->phpAttributes; - } -} diff --git a/src/Model/GeneratorConfiguration.php b/src/Model/GeneratorConfiguration.php index 669cd534..2d004498 100644 --- a/src/Model/GeneratorConfiguration.php +++ b/src/Model/GeneratorConfiguration.php @@ -5,16 +5,20 @@ namespace PHPModelGenerator\Model; use Exception; +use PHPModelGenerator\Draft\AutoDetectionDraft; +use PHPModelGenerator\Draft\DraftFactoryInterface; +use PHPModelGenerator\Draft\DraftInterface; +use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\InvalidFilterException; use PHPModelGenerator\Filter\FilterInterface; use PHPModelGenerator\Filter\TransformingFilterInterface; use PHPModelGenerator\Format\FormatValidatorInterface; +use PHPModelGenerator\Model\Attributes\PhpAttribute; use PHPModelGenerator\PropertyProcessor\Filter\DateTimeFilter; use PHPModelGenerator\PropertyProcessor\Filter\NotEmptyFilter; use PHPModelGenerator\PropertyProcessor\Filter\TrimFilter; use PHPModelGenerator\Utils\ClassNameGenerator; use PHPModelGenerator\Utils\ClassNameGeneratorInterface; -use PHPModelGenerator\Exception\ErrorRegistryException; /** * Class GeneratorConfiguration @@ -41,6 +45,15 @@ class GeneratorConfiguration protected $errorRegistryClass = ErrorRegistryException::class; /** @var bool */ protected $serialization = false; + /** @var int */ + protected $enabledAttributes = PhpAttribute::JSON_POINTER + | PhpAttribute::SCHEMA_NAME + | PhpAttribute::REQUIRED + | PhpAttribute::READ_WRITE_ONLY + | PhpAttribute::DEPRECATED; + + /** @var DraftInterface | DraftFactoryInterface */ + protected $draft; /** @var ClassNameGeneratorInterface */ protected $classNameGenerator; @@ -55,6 +68,7 @@ class GeneratorConfiguration */ public function __construct() { + $this->draft = new AutoDetectionDraft(); $this->classNameGenerator = new ClassNameGenerator(); // add all built-in filter and format validators @@ -83,15 +97,6 @@ public function addFilter(FilterInterface ...$additionalFilter): self ); } - foreach ($filter->getAcceptedTypes() as $acceptedType) { - if ( - !in_array($acceptedType, ['integer', 'number', 'boolean', 'string', 'array', 'null']) && - !class_exists($acceptedType) - ) { - throw new InvalidFilterException('Filter accepts invalid types'); - } - } - $this->filter[$filter->getToken()] = $filter; } @@ -244,6 +249,18 @@ public function setErrorRegistryClass(string $errorRegistryClass): self return $this; } + public function getDraft(): DraftInterface | DraftFactoryInterface + { + return $this->draft; + } + + public function setDraft(DraftInterface | DraftFactoryInterface $draft): self + { + $this->draft = $draft; + + return $this; + } + public function isImplicitNullAllowed(): bool { return $this->allowImplicitNull; @@ -264,6 +281,32 @@ private function initFilter(): void ->addFilter(new TrimFilter()); } + public function getEnabledAttributes(): int + { + return $this->enabledAttributes; + } + + public function setEnabledAttributes(int $enabledAttributes): self + { + $this->enabledAttributes = $enabledAttributes | PhpAttribute::ALWAYS_ENABLED_ATTRIBUTES; + + return $this; + } + + public function enableAttributes(int $attributes): self + { + $this->enabledAttributes = $this->enabledAttributes | $attributes; + + return $this; + } + + public function disableAttributes(int $attributes): self + { + $this->enabledAttributes = $this->enabledAttributes & ~$attributes | PhpAttribute::ALWAYS_ENABLED_ATTRIBUTES; + + return $this; + } + // TODO: add builtin format validators private function initFormatValidator(): void { diff --git a/src/Model/Property/AbstractProperty.php b/src/Model/Property/AbstractProperty.php index 1d585d86..78bc36f5 100644 --- a/src/Model/Property/AbstractProperty.php +++ b/src/Model/Property/AbstractProperty.php @@ -5,8 +5,7 @@ namespace PHPModelGenerator\Model\Property; use PHPModelGenerator\Exception\SchemaException; -use PHPModelGenerator\Model\AttributesTrait; -use PHPModelGenerator\Model\PhpAttribute; +use PHPModelGenerator\Model\Attributes\AttributesTrait; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchemaTrait; use PHPModelGenerator\Utils\NormalizedName; @@ -50,11 +49,11 @@ public function getName(): string */ public function getAttribute(bool $variableName = false): string { - $attribute = !$this->isInternal() && $variableName && preg_match('/^\d/', $this->attribute) === 1 - ? 'numeric_property_' . $this->attribute - : $this->attribute; + if (!$this->isInternal() && $variableName && preg_match('/^\d/', $this->attribute) === 1) { + return 'numeric_property_' . $this->attribute; + } - return ($this->isInternal() ? '_' : '') . $attribute; + return $this->attribute; } /** diff --git a/src/Model/Property/Property.php b/src/Model/Property/Property.php index b48968f1..bd88777b 100644 --- a/src/Model/Property/Property.php +++ b/src/Model/Property/Property.php @@ -4,7 +4,9 @@ namespace PHPModelGenerator\Model\Property; +use PHPModelGenerator\Attributes\Internal; use PHPModelGenerator\Exception\SchemaException; +use PHPModelGenerator\Model\Attributes\PhpAttribute; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator; @@ -335,7 +337,14 @@ public function getNestedSchema(): ?Schema */ public function setInternal(bool $isPropertyInternal): PropertyInterface { - $this->isPropertyInternal = $isPropertyInternal; + // Internal is a one-way flag: once set to true the #[Internal] attribute has been + // added to the property's attribute list and cannot be removed. Subsequent calls + // with false (or repeated calls with true) are therefore silently ignored. + if ($isPropertyInternal && !$this->isPropertyInternal) { + $this->isPropertyInternal = true; + $this->addAttribute(new PhpAttribute(Internal::class)); + } + return $this; } diff --git a/src/Model/Property/PropertyInterface.php b/src/Model/Property/PropertyInterface.php index 3ab402f9..e6bc37e7 100644 --- a/src/Model/Property/PropertyInterface.php +++ b/src/Model/Property/PropertyInterface.php @@ -4,7 +4,8 @@ namespace PHPModelGenerator\Model\Property; -use PHPModelGenerator\Model\PhpAttribute; +use PHPModelGenerator\Model\Attributes\PhpAttribute; +use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator; @@ -147,7 +148,11 @@ public function getNestedSchema(): ?Schema; */ public function getJsonSchema(): JsonSchema; - public function addAttribute(PhpAttribute $attribute): static; + public function addAttribute( + PhpAttribute $attribute, + ?GeneratorConfiguration $generatorConfiguration = null, + ?int $enablementFlag = null, + ): static; /** * @return PhpAttribute[] diff --git a/src/Model/Property/PropertyProxy.php b/src/Model/Property/PropertyProxy.php index 230618e8..1777c34c 100644 --- a/src/Model/Property/PropertyProxy.php +++ b/src/Model/Property/PropertyProxy.php @@ -5,10 +5,11 @@ namespace PHPModelGenerator\Model\Property; use PHPModelGenerator\Exception\SchemaException; -use PHPModelGenerator\Model\PhpAttribute; +use PHPModelGenerator\Model\Attributes\PhpAttribute; +use PHPModelGenerator\Model\GeneratorConfiguration; +use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\SchemaDefinition\ResolvedDefinitionsCollection; -use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\Validator\PropertyValidatorInterface; use PHPModelGenerator\PropertyProcessor\Decorator\Property\PropertyDecoratorInterface; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecoratorInterface; @@ -252,8 +253,11 @@ public function isInternal(): bool /** * @inheritdoc */ - public function addAttribute(PhpAttribute $attribute): static - { + public function addAttribute( + PhpAttribute $attribute, + ?GeneratorConfiguration $generatorConfiguration = null, + ?int $enablementFlag = null, + ): static { $this->getProperty()->addAttribute($attribute); return $this; diff --git a/src/Model/RenderJob.php b/src/Model/RenderJob.php index eefb310f..95040d46 100644 --- a/src/Model/RenderJob.php +++ b/src/Model/RenderJob.php @@ -6,10 +6,11 @@ use PHPMicroTemplate\Exception\PHPMicroTemplateException; use PHPMicroTemplate\Render; +use PHPModelGenerator\Attributes\Internal; use PHPModelGenerator\Exception\FileSystemException; use PHPModelGenerator\Exception\RenderException; use PHPModelGenerator\Exception\ValidationException; -use PHPModelGenerator\Model\PhpAttribute; +use PHPModelGenerator\Model\Attributes\PhpAttribute; use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookResolver; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; @@ -158,6 +159,8 @@ protected function getUseForSchema(GeneratorConfiguration $generatorConfiguratio } } + $attributeFqcns[] = Internal::class; + return RenderHelper::filterClassImports( array_unique( array_merge( diff --git a/src/Model/Schema.php b/src/Model/Schema.php index fd4a350f..b15de802 100644 --- a/src/Model/Schema.php +++ b/src/Model/Schema.php @@ -4,17 +4,22 @@ namespace PHPModelGenerator\Model; +use PHPModelGenerator\Attributes\Deprecated; +use PHPModelGenerator\Attributes\JsonPointer; +use PHPModelGenerator\Attributes\JsonSchema as JsonSchemaAttribute; +use PHPModelGenerator\Attributes\Source; +use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Interfaces\JSONModelInterface; +use PHPModelGenerator\Model\Attributes\AttributesTrait; +use PHPModelGenerator\Model\Attributes\PhpAttribute; use PHPModelGenerator\Model\Property\PropertyInterface; -use PHPModelGenerator\Model\AttributesTrait; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchemaTrait; use PHPModelGenerator\Model\SchemaDefinition\SchemaDefinitionDictionary; -use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; use PHPModelGenerator\Model\Validator\PropertyValidatorInterface; use PHPModelGenerator\Model\Validator\SchemaDependencyValidator; -use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor; +use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookInterface; use PHPModelGenerator\Utils\PropertyMerger; @@ -77,7 +82,34 @@ public function __construct( $this->description = $schema->getJson()['description'] ?? ''; $this->propertyMerger = new PropertyMerger($generatorConfiguration); - $this->addInterface(JSONModelInterface::class); + $this + ->addInterface(JSONModelInterface::class) + ->addAttribute( + new PhpAttribute(JsonPointer::class, [$schema->getPointer()]), + $generatorConfiguration, + PhpAttribute::JSON_POINTER, + ) + ->addAttribute( + new PhpAttribute( + JsonSchemaAttribute::class, + [empty($schema->getJson()) ? '{}' : json_encode($schema->getJson())], + ), + $generatorConfiguration, + PhpAttribute::JSON_SCHEMA, + ) + ->addAttribute( + new PhpAttribute(Source::class, [$schema->getFile()]), + $generatorConfiguration, + PhpAttribute::SOURCE, + ); + + if (isset($schema->getJson()['deprecated']) && $schema->getJson()['deprecated'] === true) { + $this->addAttribute( + new PhpAttribute(Deprecated::class), + $generatorConfiguration, + PhpAttribute::DEPRECATED, + ); + } } public function getTargetFileName(): string @@ -172,7 +204,7 @@ public function addProperty(PropertyInterface $property, ?string $compositionPro $this->propertyMerger->merge( $this->properties[$property->getName()], $property, - is_a($compositionProcessor, AllOfProcessor::class, true), + is_a($compositionProcessor, AllOfValidatorFactory::class, true), ); return $this; diff --git a/src/Model/SchemaDefinition/JsonSchema.php b/src/Model/SchemaDefinition/JsonSchema.php index 6ceb9043..6c0b5d0e 100644 --- a/src/Model/SchemaDefinition/JsonSchema.php +++ b/src/Model/SchemaDefinition/JsonSchema.php @@ -4,6 +4,8 @@ namespace PHPModelGenerator\Model\SchemaDefinition; +use PHPModelGenerator\Exception\GeneratorException; +use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Utils\ArrayHash; /** @@ -40,8 +42,9 @@ class JsonSchema * * @param string $file the source file for the schema * @param array $json Decoded json schema + * @param string $pointer The JSON pointer inside the $file leading to the schema provided in $json */ - public function __construct(private string $file, array $json) + public function __construct(private string $file, array $json, private string $pointer = '') { // wrap in an allOf to pass the processing to multiple handlers - ugly hack to be removed after rework if ( @@ -90,8 +93,51 @@ public function withJson(array $json): JsonSchema return $jsonSchema; } + /** + * Creates a clone of the JsonSchema object with a subschema, + * navigated to the provided $pointer from the current schema. + */ + public function navigate(string | int $pointer): JsonSchema + { + $trimmed = trim((string) $pointer, '/'); + + if ($trimmed === '') { + return $this; + } + + $jsonSchema = clone $this; + + foreach (explode('/', $trimmed) as $pathSegment) { + $jsonSchema->pointer .= "/$pathSegment"; + $decodedPathSegment = self::decodePointer($pathSegment); + + if (!array_key_exists($decodedPathSegment, $jsonSchema->json)) { + throw new SchemaException("Unresolved path segment $pathSegment in file $this->file"); + } + + $jsonSchema->json = $jsonSchema->json[$decodedPathSegment]; + } + + return $jsonSchema; + } + public function getFile(): string { return $this->file; } + + 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/SchemaDefinition.php b/src/Model/SchemaDefinition/SchemaDefinition.php index 8f5dc7b6..95e42be5 100644 --- a/src/Model/SchemaDefinition/SchemaDefinition.php +++ b/src/Model/SchemaDefinition/SchemaDefinition.php @@ -9,9 +9,7 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Property\PropertyProxy; use PHPModelGenerator\Model\Schema; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; /** @@ -51,23 +49,15 @@ public function getSchema(): Schema */ public function resolveReference( string $propertyName, - array $path, - PropertyMetaDataCollection $propertyMetaDataCollection, + string $path, + bool $required, + ?array $dependencies = null, ): PropertyInterface { - $jsonSchema = $this->source->getJson(); - $originalPath = $path; - - while ($segment = array_shift($path)) { - if (!isset($jsonSchema[$segment])) { - throw new SchemaException("Unresolved path segment $segment in file {$this->source->getFile()}"); - } - - $jsonSchema = $jsonSchema[$segment]; - } + $jsonSchema = $this->source->navigate($path); // if the properties point to the same definition and share identical metadata the generated property can be // recycled. Otherwise, a new property must be generated as diverging metadata lead to different validators. - $key = implode('-', [...$originalPath, $propertyMetaDataCollection->getHash($propertyName)]); + $key = implode('-', [$path, $required ? '1' : '0', md5(json_encode($dependencies))]); if (!$this->resolvedPaths->offsetExists($key)) { // create a dummy entry for the path first. If the path is used recursive the recursive usages will point @@ -75,14 +65,15 @@ public function resolveReference( $this->resolvedPaths->offsetSet($key, null); try { - $property = (new PropertyFactory(new PropertyProcessorFactory())) + $property = (new PropertyFactory()) ->create( - $propertyMetaDataCollection, $this->schemaProcessor, $this->schema, $propertyName, - $this->source->withJson($jsonSchema), + $jsonSchema, + $required, ); + $this->resolvedPaths->offsetSet($key, $property); /** @var PropertyProxy $proxy */ diff --git a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php index 7d5d6bd8..d71ddce8 100644 --- a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php +++ b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php @@ -46,7 +46,11 @@ public function setUpDefinitionDictionary(SchemaProcessor $schemaProcessor, Sche // add the root nodes of the schema to resolve path references $this->addDefinition( $key, - new SchemaDefinition($schema->getJsonSchema()->withJson($propertyEntry), $schemaProcessor, $schema), + new SchemaDefinition( + $schema->getJsonSchema()->navigate(JsonSchema::encodePointer($key)), + $schemaProcessor, + $schema, + ), ); } @@ -70,12 +74,16 @@ protected function fetchDefinitionsById( ); } - foreach ($json as $item) { + foreach ($json as $key => $item) { if (!is_array($item)) { continue; } - $this->fetchDefinitionsById($jsonSchema->withJson($item), $schemaProcessor, $schema); + $this->fetchDefinitionsById( + $jsonSchema->navigate(JsonSchema::encodePointer($key)), + $schemaProcessor, + $schema, + ); } } diff --git a/src/Model/Validator/AdditionalPropertiesValidator.php b/src/Model/Validator/AdditionalPropertiesValidator.php index c9a0938d..ca0b18fa 100644 --- a/src/Model/Validator/AdditionalPropertiesValidator.php +++ b/src/Model/Validator/AdditionalPropertiesValidator.php @@ -10,9 +10,7 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -44,14 +42,14 @@ public function __construct( JsonSchema $propertiesStructure, ?string $propertyName = null, ) { - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); + $propertyFactory = new PropertyFactory(); $this->validationProperty = $propertyFactory->create( - new PropertyMetaDataCollection([static::PROPERTY_NAME]), $schemaProcessor, $schema, static::PROPERTY_NAME, - $propertiesStructure->withJson($propertiesStructure->getJson()[static::ADDITIONAL_PROPERTIES_KEY]), + $propertiesStructure->navigate(static::ADDITIONAL_PROPERTIES_KEY), + true, ); $this->validationProperty->onResolve(function (): void { diff --git a/src/Model/Validator/ArrayItemValidator.php b/src/Model/Validator/ArrayItemValidator.php index 14b95587..ddfa5971 100644 --- a/src/Model/Validator/ArrayItemValidator.php +++ b/src/Model/Validator/ArrayItemValidator.php @@ -10,9 +10,7 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ArrayTypeHintDecorator; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -41,9 +39,8 @@ public function __construct( $this->variableSuffix = '_' . md5($nestedPropertyName); // an item of the array behaves like a nested property to add item-level validation - $this->nestedProperty = (new PropertyFactory(new PropertyProcessorFactory())) + $this->nestedProperty = (new PropertyFactory()) ->create( - new PropertyMetaDataCollection(), $schemaProcessor, $schema, $nestedPropertyName, diff --git a/src/Model/Validator/ArrayTupleValidator.php b/src/Model/Validator/ArrayTupleValidator.php index 487b1657..8c89ad3c 100644 --- a/src/Model/Validator/ArrayTupleValidator.php +++ b/src/Model/Validator/ArrayTupleValidator.php @@ -10,9 +10,7 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -37,7 +35,7 @@ public function __construct( JsonSchema $propertiesStructure, string $propertyName, ) { - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); + $propertyFactory = new PropertyFactory(); $this->tupleProperties = []; @@ -46,11 +44,11 @@ public function __construct( // an item of the array behaves like a nested property to add item-level validation $tupleProperty = $propertyFactory->create( - new PropertyMetaDataCollection([$tupleItemName]), $schemaProcessor, $schema, $tupleItemName, - $propertiesStructure->withJson($tupleItem), + $propertiesStructure->navigate($tupleIndex), + true, ); $this->tupleProperties[] = $tupleProperty; diff --git a/src/Model/Validator/ComposedPropertyValidator.php b/src/Model/Validator/ComposedPropertyValidator.php index a1011f4f..fa074662 100644 --- a/src/Model/Validator/ComposedPropertyValidator.php +++ b/src/Model/Validator/ComposedPropertyValidator.php @@ -4,7 +4,6 @@ namespace PHPModelGenerator\Model\Validator; -use PHPModelGenerator\Exception\ComposedValue\InvalidComposedValueException; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Model\MethodInterface; use PHPModelGenerator\Model\Property\CompositionPropertyDecorator; @@ -25,6 +24,7 @@ public function __construct( PropertyInterface $property, array $composedProperties, string $compositionProcessor, + string $exceptionClass, array $validatorVariables, ) { $this->modifiedValuesMethod = '_getModifiedValues_' . substr(md5(spl_object_hash($this)), 0, 5); @@ -35,7 +35,7 @@ public function __construct( $property, DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ComposedItem.phptpl', array_merge($validatorVariables, ['modifiedValuesMethod' => $this->modifiedValuesMethod]), - $this->getExceptionByProcessor($compositionProcessor), + $exceptionClass, ['&$succeededCompositionElements', '&$compositionErrorCollection'], ); @@ -141,20 +141,4 @@ public function withoutNestedCompositionValidation(): self return $validator; } - - /** - * Parse the composition type (allOf, anyOf, ...) from the given processor and get the corresponding exception class - */ - private function getExceptionByProcessor(string $compositionProcessor): string - { - return str_replace( - DIRECTORY_SEPARATOR, - '\\', - dirname(str_replace('\\', DIRECTORY_SEPARATOR, InvalidComposedValueException::class)), - ) . '\\' . str_replace( - 'Processor', - '', - substr($compositionProcessor, strrpos($compositionProcessor, '\\') + 1), - ) . 'Exception'; - } } diff --git a/src/Model/Validator/ConditionalPropertyValidator.php b/src/Model/Validator/ConditionalPropertyValidator.php index 44667125..c256d43d 100644 --- a/src/Model/Validator/ConditionalPropertyValidator.php +++ b/src/Model/Validator/ConditionalPropertyValidator.php @@ -8,7 +8,7 @@ use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Model\Property\CompositionPropertyDecorator; use PHPModelGenerator\Model\Property\PropertyInterface; -use PHPModelGenerator\PropertyProcessor\ComposedValue\IfProcessor; +use PHPModelGenerator\Model\Validator\Factory\Composition\IfValidatorFactory; /** * Class ConditionalPropertyValidator @@ -38,7 +38,7 @@ public function __construct( ['&$ifException', '&$thenException', '&$elseException'], ); - $this->compositionProcessor = IfProcessor::class; + $this->compositionProcessor = IfValidatorFactory::class; $this->composedProperties = $composedProperties; $this->conditionBranches = $conditionBranches; } diff --git a/src/Model/Validator/Factory/AbstractValidatorFactory.php b/src/Model/Validator/Factory/AbstractValidatorFactory.php new file mode 100644 index 00000000..9545a705 --- /dev/null +++ b/src/Model/Validator/Factory/AbstractValidatorFactory.php @@ -0,0 +1,17 @@ +key = $key; + } +} diff --git a/src/Model/Validator/Factory/Any/EnumValidatorFactory.php b/src/Model/Validator/Factory/Any/EnumValidatorFactory.php new file mode 100644 index 00000000..8914e2dd --- /dev/null +++ b/src/Model/Validator/Factory/Any/EnumValidatorFactory.php @@ -0,0 +1,96 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $allowedValues = $json[$this->key]; + + if (empty($allowedValues)) { + throw new SchemaException( + sprintf( + "Empty enum property %s in file %s", + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + $allowedValues = array_unique($allowedValues); + + if (array_key_exists('default', $json)) { + if (!in_array($json['default'], $allowedValues, true)) { + throw new SchemaException( + sprintf( + "Invalid default value %s for enum property %s in file %s", + var_export($json['default'], true), + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + } + + // no type information provided - inherit the types from the enum values + if (!$property->getType()) { + $typesOfEnum = array_unique(array_map( + static fn($value): string => TypeConverter::gettypeToInternal(gettype($value)), + $allowedValues, + )); + + if (count($typesOfEnum) === 1) { + $property->setType(new PropertyType($typesOfEnum[0])); + } else { + $hasNull = in_array('null', $typesOfEnum, true); + $nonNullTypes = array_values(array_filter( + $typesOfEnum, + static fn(string $t): bool => $t !== 'null', + )); + + if ($nonNullTypes) { + $propertyType = new PropertyType($nonNullTypes, $hasNull ? true : null); + $property->setType($propertyType, $propertyType); + } + } + + $property->addTypeHintDecorator(new TypeHintDecorator($typesOfEnum)); + } + + $implicitNullAllowed = $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() + && !$property->isRequired(); + + if ($implicitNullAllowed && !in_array(null, $allowedValues, true)) { + $allowedValues[] = null; + } + + $property->addValidator(new EnumValidator($property, $allowedValues), 3); + } +} diff --git a/src/Model/Validator/Factory/Any/FilterValidatorFactory.php b/src/Model/Validator/Factory/Any/FilterValidatorFactory.php new file mode 100644 index 00000000..e5326157 --- /dev/null +++ b/src/Model/Validator/Factory/Any/FilterValidatorFactory.php @@ -0,0 +1,41 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + (new FilterProcessor())->process( + $property, + $json[$this->key], + $schemaProcessor->getGeneratorConfiguration(), + $schema, + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php new file mode 100644 index 00000000..c0f99092 --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php @@ -0,0 +1,57 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $nestedProperty = (new PropertyFactory()) + ->create( + $schemaProcessor, + $schema, + "item of array {$property->getName()}", + $propertySchema->navigate($this->key), + ); + + $property->addValidator( + new PropertyTemplateValidator( + $property, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ArrayContains.phptpl', + [ + 'property' => $nestedProperty, + 'schema' => $schema, + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + ], + ContainsException::class, + ), + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php new file mode 100644 index 00000000..2eb1dfac --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php @@ -0,0 +1,118 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $itemsSchema = $json[$this->key]; + + // tuple validation: items is a sequential array + if ( + is_array($itemsSchema) && + array_keys($itemsSchema) === range(0, count($itemsSchema) - 1) + ) { + $this->addTupleValidator($schemaProcessor, $schema, $property, $propertySchema); + + return; + } + + $property->addValidator( + new ArrayItemValidator( + $schemaProcessor, + $schema, + $propertySchema->navigate($this->key), + $property, + ), + ); + } + + /** + * @throws SchemaException + */ + private function addTupleValidator( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + $json = $propertySchema->getJson(); + + if (isset($json['additionalItems']) && $json['additionalItems'] !== true) { + $this->addAdditionalItemsValidator($schemaProcessor, $schema, $property, $propertySchema); + } + + $property->addValidator( + new ArrayTupleValidator( + $schemaProcessor, + $schema, + $propertySchema->navigate($this->key), + $property->getName(), + ), + ); + } + + /** + * @throws SchemaException + */ + private function addAdditionalItemsValidator( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + $json = $propertySchema->getJson(); + + if (!is_bool($json['additionalItems'])) { + $property->addValidator( + new AdditionalItemsValidator( + $schemaProcessor, + $schema, + $propertySchema, + $property->getName(), + ), + ); + + return; + } + + $expectedAmount = count($json[$this->key]); + + $property->addValidator( + new PropertyValidator( + $property, + '($amount = count($value)) > ' . $expectedAmount, + AdditionalTupleItemsException::class, + [$expectedAmount, '&$amount'], + ), + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/MaxItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/MaxItemsValidatorFactory.php new file mode 100644 index 00000000..4d7b940e --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/MaxItemsValidatorFactory.php @@ -0,0 +1,29 @@ += 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + "is_array(\$value) && count(\$value) > $value", + MaxItemsException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/MinItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/MinItemsValidatorFactory.php new file mode 100644 index 00000000..188ae00b --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/MinItemsValidatorFactory.php @@ -0,0 +1,29 @@ += 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + "is_array(\$value) && count(\$value) < $value", + MinItemsException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/UniqueItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/UniqueItemsValidatorFactory.php new file mode 100644 index 00000000..7a955751 --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/UniqueItemsValidatorFactory.php @@ -0,0 +1,38 @@ +getJson(); + + if (!isset($json[$this->key]) || $json[$this->key] !== true) { + return; + } + + $property->addValidator( + new PropertyTemplateValidator( + $property, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ArrayUnique.phptpl', + [], + UniqueItemsException::class, + ), + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php new file mode 100644 index 00000000..98535951 --- /dev/null +++ b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php @@ -0,0 +1,217 @@ +getJson()[$this->key]) && + $schemaProcessor->getGeneratorConfiguration()->isOutputEnabled() + ) { + // @codeCoverageIgnoreStart + echo "Warning: empty composition for {$property->getName()} may lead to unexpected results\n"; + // @codeCoverageIgnoreEnd + } + } + + /** + * Returns true when composition processing should be skipped for this property. + * + * For non-root object-typed properties, composition keywords are processed inside + * the nested schema by processSchema (with the type=base path). Adding a composition + * validator at the parent level would duplicate validation and inject a _Merged_ type + * hint that overrides the correct nested-class type. + */ + protected function shouldSkip(PropertyInterface $property, JsonSchema $propertySchema): bool + { + return !($property instanceof BaseProperty) + && ($propertySchema->getJson()['type'] ?? '') === 'object'; + } + + /** + * Build composition sub-properties for the current keyword's branches. + * + * @param bool $merged Whether to suppress CompositionTypeHintDecorators for object branches. + * + * @return CompositionPropertyDecorator[] + * + * @throws SchemaException + */ + protected function getCompositionProperties( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + bool $merged, + ): array { + $propertyFactory = new PropertyFactory(); + $compositionProperties = []; + $json = $propertySchema->getJson()['propertySchema']->getJson(); + + $property->addTypeHintDecorator(new ClearTypeHintDecorator()); + + foreach ($json[$this->key] as $index => $compositionElement) { + $compositionSchema = $propertySchema->getJson()['propertySchema']->navigate("$this->key/$index"); + + $compositionProperty = new CompositionPropertyDecorator( + $property->getName(), + $compositionSchema, + $propertyFactory->create( + $schemaProcessor, + $schema, + $property->getName(), + $compositionSchema, + $property->isRequired(), + ), + ); + + $compositionProperty->onResolve(function () use ($compositionProperty, $property, $merged): void { + $compositionProperty->filterValidators( + static fn(Validator $validator): bool => + !is_a($validator->getValidator(), RequiredPropertyValidator::class) && + !is_a($validator->getValidator(), ComposedPropertyValidator::class), + ); + + if (!($merged && $compositionProperty->getNestedSchema())) { + $property->addTypeHintDecorator(new CompositionTypeHintDecorator($compositionProperty)); + } + }); + + $compositionProperties[] = $compositionProperty; + } + + return $compositionProperties; + } + + /** + * Inherit a parent-level type into composition branches that declare no type. + */ + protected function inheritPropertyType(JsonSchema $propertySchema): JsonSchema + { + $json = $propertySchema->getJson(); + + if (!isset($json['type'])) { + return $propertySchema; + } + + switch ($this->key) { + case 'not': + if (!isset($json[$this->key]['type'])) { + $json[$this->key]['type'] = $json['type']; + } + break; + case 'if': + return $this->inheritIfPropertyType($propertySchema->withJson($json)); + default: + foreach ($json[$this->key] as &$composedElement) { + if (!isset($composedElement['type'])) { + $composedElement['type'] = $json['type']; + } + } + } + + return $propertySchema->withJson($json); + } + + /** + * Inherit the parent type into all branches of an if/then/else composition. + */ + protected function inheritIfPropertyType(JsonSchema $propertySchema): JsonSchema + { + $json = $propertySchema->getJson(); + + foreach (['if', 'then', 'else'] as $keyword) { + if (!isset($json[$keyword])) { + continue; + } + + if (!isset($json[$keyword]['type'])) { + $json[$keyword]['type'] = $json['type']; + } + } + + return $propertySchema->withJson($json); + } + + /** + * After all composition branches resolve, attempt to widen the parent property's type + * to cover all branch types. Skips for branches with nested schemas. + * + * @param bool $isAllOf Whether allOf semantics apply (affects nullable detection). + * @param CompositionPropertyDecorator[] $compositionProperties + */ + protected function transferPropertyType( + PropertyInterface $property, + array $compositionProperties, + bool $isAllOf, + ): void { + foreach ($compositionProperties as $compositionProperty) { + if ($compositionProperty->getNestedSchema() !== null) { + return; + } + } + + $allNames = array_merge(...array_map( + static fn(CompositionPropertyDecorator $p): array => + $p->getType() ? $p->getType()->getNames() : [], + $compositionProperties, + )); + + $hasBranchWithNoType = array_filter( + $compositionProperties, + static fn(CompositionPropertyDecorator $p): bool => $p->getType() === null, + ) !== []; + + $hasBranchWithRequiredProperty = array_filter( + $compositionProperties, + static fn(CompositionPropertyDecorator $p): bool => $p->isRequired(), + ) !== []; + + $hasBranchWithOptionalProperty = $isAllOf + ? !$hasBranchWithRequiredProperty + : array_filter( + $compositionProperties, + static fn(CompositionPropertyDecorator $p): bool => !$p->isRequired(), + ) !== []; + + $hasNull = in_array('null', $allNames, true); + $nonNullNames = array_values(array_filter( + array_unique($allNames), + fn(string $t): bool => $t !== 'null', + )); + + if (!$nonNullNames) { + return; + } + + $nullable = ($hasNull || $hasBranchWithNoType || $hasBranchWithOptionalProperty) ? true : null; + + $property->setType(new PropertyType($nonNullNames, $nullable)); + } +} diff --git a/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php new file mode 100644 index 00000000..9b76ec2e --- /dev/null +++ b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php @@ -0,0 +1,104 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); + $propertySchema = $this->inheritPropertyType($propertySchema); + + $wrappedSchema = $propertySchema->withJson([ + 'type' => $this->key, + 'propertySchema' => $propertySchema, + 'onlyForDefinedValues' => false, + ]); + + $compositionProperties = $this->getCompositionProperties( + $schemaProcessor, + $schema, + $property, + $wrappedSchema, + true, + ); + + $resolvedCompositions = 0; + $mergedProperty = null; + foreach ($compositionProperties as $compositionProperty) { + $compositionProperty->onResolve( + function () use ( + &$resolvedCompositions, + &$mergedProperty, + $property, + $compositionProperties, + $wrappedSchema, + $schemaProcessor, + $schema, + ): void { + if (++$resolvedCompositions === count($compositionProperties)) { + $this->transferPropertyType($property, $compositionProperties, true); + + $mergedProperty = !($property instanceof BaseProperty) + ? $schemaProcessor->createMergedProperty( + $schema, + $property, + $compositionProperties, + $wrappedSchema, + ) + : null; + } + }, + ); + } + + $availableAmount = count($compositionProperties); + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + AllOfException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => $availableAmount, + 'composedValueValidation' => "\$succeededCompositionElements === $availableAmount", + 'postPropose' => true, + 'mergedProperty' => &$mergedProperty, + 'onlyForDefinedValues' => false, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php new file mode 100644 index 00000000..33c25085 --- /dev/null +++ b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php @@ -0,0 +1,108 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); + $propertySchema = $this->inheritPropertyType($propertySchema); + + $onlyForDefinedValues = !($property instanceof BaseProperty) + && (!$property->isRequired() + && $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()); + + $wrappedSchema = $propertySchema->withJson([ + 'type' => $this->key, + 'propertySchema' => $propertySchema, + 'onlyForDefinedValues' => $onlyForDefinedValues, + ]); + + $compositionProperties = $this->getCompositionProperties( + $schemaProcessor, + $schema, + $property, + $wrappedSchema, + true, + ); + + $resolvedCompositions = 0; + $mergedProperty = null; + foreach ($compositionProperties as $compositionProperty) { + $compositionProperty->onResolve( + function () use ( + &$resolvedCompositions, + &$mergedProperty, + $property, + $compositionProperties, + $wrappedSchema, + $schemaProcessor, + $schema, + ): void { + if (++$resolvedCompositions === count($compositionProperties)) { + $this->transferPropertyType($property, $compositionProperties, false); + + $mergedProperty = !($property instanceof BaseProperty) + ? $schemaProcessor->createMergedProperty( + $schema, + $property, + $compositionProperties, + $wrappedSchema, + ) + : null; + } + }, + ); + } + + $availableAmount = count($compositionProperties); + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + AnyOfException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => $availableAmount, + 'composedValueValidation' => '$succeededCompositionElements > 0', + 'postPropose' => true, + 'mergedProperty' => &$mergedProperty, + 'onlyForDefinedValues' => $onlyForDefinedValues, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/ComposedPropertiesValidatorFactoryInterface.php b/src/Model/Validator/Factory/Composition/ComposedPropertiesValidatorFactoryInterface.php new file mode 100644 index 00000000..fb29fc5a --- /dev/null +++ b/src/Model/Validator/Factory/Composition/ComposedPropertiesValidatorFactoryInterface.php @@ -0,0 +1,16 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + $json = $propertySchema->getJson(); + + if (!isset($json['then']) && !isset($json['else'])) { + throw new SchemaException( + sprintf( + 'Incomplete conditional composition for property %s in file %s', + $property->getName(), + $property->getJsonSchema()->getFile(), + ), + ); + } + + $propertySchema = $this->inheritPropertyType($propertySchema); + $json = $propertySchema->getJson(); + + $propertyFactory = new PropertyFactory(); + + $onlyForDefinedValues = !($property instanceof BaseProperty) + && (!$property->isRequired() + && $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()); + + /** @var array $properties */ + $properties = []; + + foreach (['if', 'then', 'else'] as $keyword) { + if (!isset($json[$keyword])) { + $properties[$keyword] = null; + continue; + } + + $compositionSchema = $propertySchema->navigate($keyword); + + $compositionProperty = new CompositionPropertyDecorator( + $property->getName(), + $compositionSchema, + $propertyFactory->create( + $schemaProcessor, + $schema, + $property->getName(), + $compositionSchema, + $property->isRequired(), + ), + ); + + $compositionProperty->onResolve(static function () use ($compositionProperty): void { + $compositionProperty->filterValidators( + static fn(Validator $validator): bool => + !is_a($validator->getValidator(), RequiredPropertyValidator::class) && + !is_a($validator->getValidator(), ComposedPropertyValidator::class), + ); + }); + + $properties[$keyword] = $compositionProperty; + } + + $property->addValidator( + new ConditionalPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + array_values(array_filter($properties)), + array_values(array_filter([$properties['then'], $properties['else']])), + [ + 'ifProperty' => $properties['if'], + 'thenProperty' => $properties['then'], + 'elseProperty' => $properties['else'], + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'onlyForDefinedValues' => $onlyForDefinedValues, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/NotValidatorFactory.php b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php new file mode 100644 index 00000000..2f240535 --- /dev/null +++ b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php @@ -0,0 +1,82 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + // Inherit the parent type into the not branch before wrapping in array. + // inheritPropertyType for 'not' treats $json['not'] as a single schema object, + // so it must run before we wrap it in an array for iteration. + $propertySchema = $this->inheritPropertyType($propertySchema); + $json = $propertySchema->getJson(); + + // Wrap the single 'not' schema in an array so getCompositionProperties can iterate it. + $json[$this->key] = [$json[$this->key]]; + $wrappedOuter = $propertySchema->withJson($json); + + // Force required=true so strict null checks apply inside the not branch. + $property->setRequired(true); + + $wrappedSchema = $wrappedOuter->withJson([ + 'type' => $this->key, + 'propertySchema' => $wrappedOuter, + 'onlyForDefinedValues' => false, + ]); + + $compositionProperties = $this->getCompositionProperties( + $schemaProcessor, + $schema, + $property, + $wrappedSchema, + false, + ); + + $availableAmount = count($compositionProperties); + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + NotException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => $availableAmount, + 'composedValueValidation' => '$succeededCompositionElements === 0', + 'postPropose' => false, + 'mergedProperty' => null, + 'onlyForDefinedValues' => false, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php new file mode 100644 index 00000000..8921981a --- /dev/null +++ b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php @@ -0,0 +1,90 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); + $propertySchema = $this->inheritPropertyType($propertySchema); + + $onlyForDefinedValues = !($property instanceof BaseProperty) + && (!$property->isRequired() + && $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()); + + $wrappedSchema = $propertySchema->withJson([ + 'type' => $this->key, + 'propertySchema' => $propertySchema, + 'onlyForDefinedValues' => $onlyForDefinedValues, + ]); + + $compositionProperties = $this->getCompositionProperties( + $schemaProcessor, + $schema, + $property, + $wrappedSchema, + false, + ); + + $resolvedCompositions = 0; + foreach ($compositionProperties as $compositionProperty) { + $compositionProperty->onResolve( + function () use (&$resolvedCompositions, $property, $compositionProperties): void { + if (++$resolvedCompositions === count($compositionProperties)) { + $this->transferPropertyType($property, $compositionProperties, false); + } + }, + ); + } + + $availableAmount = count($compositionProperties); + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + OneOfException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => $availableAmount, + 'composedValueValidation' => '$succeededCompositionElements === 1', + 'postPropose' => true, + 'mergedProperty' => null, + 'onlyForDefinedValues' => $onlyForDefinedValues, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Number/AbstractRangeValidatorFactory.php b/src/Model/Validator/Factory/Number/AbstractRangeValidatorFactory.php new file mode 100644 index 00000000..377b018f --- /dev/null +++ b/src/Model/Validator/Factory/Number/AbstractRangeValidatorFactory.php @@ -0,0 +1,35 @@ +typeCheck}(\$value) && \$value {$this->getOperator()} $value", + $this->getExceptionClass(), + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Number/ExclusiveMaximumValidatorFactory.php b/src/Model/Validator/Factory/Number/ExclusiveMaximumValidatorFactory.php new file mode 100644 index 00000000..aaca9540 --- /dev/null +++ b/src/Model/Validator/Factory/Number/ExclusiveMaximumValidatorFactory.php @@ -0,0 +1,20 @@ +='; + } + + protected function getExceptionClass(): string + { + return ExclusiveMaximumException::class; + } +} diff --git a/src/Model/Validator/Factory/Number/ExclusiveMinimumValidatorFactory.php b/src/Model/Validator/Factory/Number/ExclusiveMinimumValidatorFactory.php new file mode 100644 index 00000000..cf9c1679 --- /dev/null +++ b/src/Model/Validator/Factory/Number/ExclusiveMinimumValidatorFactory.php @@ -0,0 +1,20 @@ +'; + } + + protected function getExceptionClass(): string + { + return MaximumException::class; + } +} diff --git a/src/Model/Validator/Factory/Number/MinimumValidatorFactory.php b/src/Model/Validator/Factory/Number/MinimumValidatorFactory.php new file mode 100644 index 00000000..5926bf2f --- /dev/null +++ b/src/Model/Validator/Factory/Number/MinimumValidatorFactory.php @@ -0,0 +1,20 @@ +typeCheck}(\$value) && \$value != 0"; + } elseif ($this->isInteger) { + $check = "{$this->typeCheck}(\$value) && \$value % $value != 0"; + } else { + $check = "{$this->typeCheck}(\$value) && fmod(\$value, $value) != 0"; + } + + return new PropertyValidator( + $property, + $check, + MultipleOfException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Object/AdditionalPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/AdditionalPropertiesValidatorFactory.php new file mode 100644 index 00000000..d9faaf66 --- /dev/null +++ b/src/Model/Validator/Factory/Object/AdditionalPropertiesValidatorFactory.php @@ -0,0 +1,60 @@ +getJson(); + + if ( + !isset($json[$this->key]) && + $schemaProcessor->getGeneratorConfiguration()->denyAdditionalProperties() + ) { + $json[$this->key] = false; + } + + if (!isset($json[$this->key]) || $json[$this->key] === true) { + return; + } + + if (!is_bool($json[$this->key])) { + $schema->addBaseValidator( + new AdditionalPropertiesValidator( + $schemaProcessor, + $schema, + $propertySchema, + ) + ); + + return; + } + + $schema->addBaseValidator( + new NoAdditionalPropertiesValidator( + new Property($schema->getClassName(), null, $propertySchema), + $json, + ) + ); + } +} diff --git a/src/Model/Validator/Factory/Object/MaxPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/MaxPropertiesValidatorFactory.php new file mode 100644 index 00000000..42576f17 --- /dev/null +++ b/src/Model/Validator/Factory/Object/MaxPropertiesValidatorFactory.php @@ -0,0 +1,39 @@ +rawModelDataInput), + array_keys($modelData), + ) + ), + )'; + + protected function isValueValid(mixed $value): bool + { + return is_int($value) && $value >= 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + sprintf('%s > %d', self::COUNT_PROPERTIES, $value), + MaxPropertiesException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Object/MinPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/MinPropertiesValidatorFactory.php new file mode 100644 index 00000000..e08a15ac --- /dev/null +++ b/src/Model/Validator/Factory/Object/MinPropertiesValidatorFactory.php @@ -0,0 +1,40 @@ +rawModelDataInput), + array_keys($modelData), + ) + ), + )'; + + protected function isValueValid(mixed $value): bool + { + return is_int($value) && $value >= 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + sprintf('%s < %d', self::COUNT_PROPERTIES, $value), + MinPropertiesException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php new file mode 100644 index 00000000..b04ff052 --- /dev/null +++ b/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php @@ -0,0 +1,51 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + foreach ($json[$this->key] as $pattern => $patternSchema) { + $escapedPattern = addcslashes((string) $pattern, '/'); + + if (@preg_match("/$escapedPattern/", '') === false) { + throw new SchemaException( + "Invalid pattern '$pattern' for pattern property in file {$propertySchema->getFile()}", + ); + } + + $schema->addBaseValidator( + new PatternPropertiesValidator( + $schemaProcessor, + $schema, + $pattern, + $propertySchema->navigate("$this->key/" . JsonSchema::encodePointer($pattern)), + ) + ); + } + } +} diff --git a/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php new file mode 100644 index 00000000..635214db --- /dev/null +++ b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php @@ -0,0 +1,147 @@ +getJson(); + + $propertyFactory = new PropertyFactory(); + + $json[$this->key] ??= []; + // Setup empty properties for required properties which aren't defined in the properties section + $json[$this->key] += array_fill_keys( + array_diff($json['required'] ?? [], array_keys($json[$this->key])), + [], + ); + + $propertySchema = $propertySchema->withJson($json); + + foreach ($json[$this->key] as $propertyName => $propertyStructure) { + if ($propertyStructure === false) { + if (in_array($propertyName, $json['required'] ?? [], true)) { + throw new SchemaException( + sprintf( + "Property '%s' is denied (schema false) but also listed as required in file %s", + $propertyName, + $propertySchema->getFile(), + ), + ); + } + + $schema->addBaseValidator( + new PropertyValidator( + new Property($propertyName, null, $propertySchema->withJson([])), + "array_key_exists('" . addslashes($propertyName) . "', \$modelData)", + DeniedPropertyException::class, + ) + ); + continue; + } + + $required = in_array($propertyName, $json['required'] ?? [], true); + $dependencies = $json['dependencies'][$propertyName] ?? null; + $nestedProperty = $propertyFactory->create( + $schemaProcessor, + $schema, + (string) $propertyName, + $propertySchema->navigate("$this->key/" . JsonSchema::encodePointer($propertyName))->withJson( + $dependencies !== null + ? $propertyStructure + ['_dependencies' => $dependencies] + : $propertyStructure, + ), + $required, + ); + + if ($dependencies !== null) { + $this->addDependencyValidator( + $nestedProperty, + $schema->getJsonSchema()->navigate("dependencies/" . JsonSchema::encodePointer($propertyName)), + $schemaProcessor, + $schema, + ); + } + + $schema->addProperty($nestedProperty); + } + } + + /** + * @throws SchemaException + */ + private function addDependencyValidator( + PropertyInterface $property, + JsonSchema $dependencyJsonSchema, + SchemaProcessor $schemaProcessor, + Schema $schema, + ): void { + $propertyDependency = true; + + foreach ($dependencyJsonSchema->getJson() as $index => $dependency) { + if (!is_int($index) || !is_string($dependency)) { + $propertyDependency = false; + break; + } + } + + if ($propertyDependency) { + $property->addValidator(new PropertyDependencyValidator($property, $dependencyJsonSchema->getJson())); + + return; + } + + $json = $dependencyJsonSchema->getJson(); + if (!isset($json['type'])) { + $dependencyJsonSchema = $dependencyJsonSchema->withJson($json + ['type' => 'object']); + } + + $dependencySchema = $schemaProcessor->processSchema( + $dependencyJsonSchema, + $schema->getClassPath(), + "{$schema->getClassName()}_{$property->getName()}_Dependency", + $schema->getSchemaDictionary(), + ); + + $property->addValidator(new SchemaDependencyValidator($schemaProcessor, $property, $dependencySchema)); + $schema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($dependencySchema)); + + $this->transferDependentPropertiesToBaseSchema($dependencySchema, $schema); + } + + private function transferDependentPropertiesToBaseSchema(Schema $dependencySchema, Schema $schema): void + { + foreach ($dependencySchema->getProperties() as $dependencyProperty) { + $schema->addProperty( + (clone $dependencyProperty) + ->setRequired(false) + ->setType(null) + ->filterValidators(static fn(): bool => false), + ); + } + } +} diff --git a/src/Model/Validator/Factory/Object/PropertyNamesValidatorFactory.php b/src/Model/Validator/Factory/Object/PropertyNamesValidatorFactory.php new file mode 100644 index 00000000..57f782b4 --- /dev/null +++ b/src/Model/Validator/Factory/Object/PropertyNamesValidatorFactory.php @@ -0,0 +1,40 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $schema->addBaseValidator( + new PropertyNamesValidator( + $schemaProcessor, + $schema, + $propertySchema->navigate($this->key), + ) + ); + } +} diff --git a/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php b/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php new file mode 100644 index 00000000..5f448964 --- /dev/null +++ b/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php @@ -0,0 +1,28 @@ +hasValidValue($property, $propertySchema)) { + return; + } + + $schema->addBaseValidator( + $this->getValidator($property, $propertySchema->getJson()[$this->key]), + ); + } +} diff --git a/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php b/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php new file mode 100644 index 00000000..29a71d79 --- /dev/null +++ b/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php @@ -0,0 +1,57 @@ +hasValidValue($property, $propertySchema)) { + return; + } + + $property->addValidator( + $this->getValidator($property, $propertySchema->getJson()[$this->key]), + ); + } + + protected function hasValidValue(PropertyInterface $property, JsonSchema $propertySchema): bool + { + $json = $propertySchema->getJson(); + + if (!isset($json[$this->key])) { + return false; + } + + if (!$this->isValueValid($json[$this->key])) { + throw new SchemaException( + sprintf( + "Invalid %s %s for property '%s' in file %s", + $this->key, + str_replace("\n", '', var_export($json[$this->key], true)), + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + return true; + } + + abstract protected function isValueValid(mixed $value): bool; + + abstract protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface; +} diff --git a/src/Model/Validator/Factory/String/FormatValidatorFactory.php b/src/Model/Validator/Factory/String/FormatValidatorFactory.php new file mode 100644 index 00000000..725066fd --- /dev/null +++ b/src/Model/Validator/Factory/String/FormatValidatorFactory.php @@ -0,0 +1,54 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $format = $json[$this->key]; + $formatValidator = $schemaProcessor->getGeneratorConfiguration()->getFormat($format); + + if (!$formatValidator) { + throw new SchemaException( + sprintf( + 'Unsupported format %s for property %s in file %s', + $format, + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + $property->addValidator( + new FormatValidator( + $property, + $formatValidator, + [$format], + ), + ); + } +} diff --git a/src/Model/Validator/Factory/String/MaxLengthValidatorFactory.php b/src/Model/Validator/Factory/String/MaxLengthValidatorFactory.php new file mode 100644 index 00000000..4caf0ba0 --- /dev/null +++ b/src/Model/Validator/Factory/String/MaxLengthValidatorFactory.php @@ -0,0 +1,23 @@ + $value", + MaxLengthException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/String/MinLengthPropertyValidatorFactory.php b/src/Model/Validator/Factory/String/MinLengthPropertyValidatorFactory.php new file mode 100644 index 00000000..98e2d54b --- /dev/null +++ b/src/Model/Validator/Factory/String/MinLengthPropertyValidatorFactory.php @@ -0,0 +1,29 @@ += 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + "is_string(\$value) && mb_strlen(\$value) < $value", + MinLengthException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/String/PatternPropertyValidatorFactory.php b/src/Model/Validator/Factory/String/PatternPropertyValidatorFactory.php new file mode 100644 index 00000000..e1fa3b6f --- /dev/null +++ b/src/Model/Validator/Factory/String/PatternPropertyValidatorFactory.php @@ -0,0 +1,58 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $pattern = (string) $json[$this->key]; + $escapedPattern = addcslashes($pattern, '/'); + + if (@preg_match("/$escapedPattern/", '') === false) { + throw new SchemaException( + sprintf( + "Invalid pattern '%s' for property '%s' in file %s", + $pattern, + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + $encodedPattern = base64_encode("/$escapedPattern/"); + + $property->addValidator( + new PropertyValidator( + $property, + "is_string(\$value) && !preg_match(base64_decode('$encodedPattern'), \$value)", + PatternException::class, + [$pattern], + ), + ); + } +} diff --git a/src/Model/Validator/FilterValidator.php b/src/Model/Validator/FilterValidator.php index 23b6fe65..8f978566 100644 --- a/src/Model/Validator/FilterValidator.php +++ b/src/Model/Validator/FilterValidator.php @@ -11,9 +11,10 @@ use PHPModelGenerator\Filter\TransformingFilterInterface; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Model\Property\PropertyInterface; +use PHPModelGenerator\Utils\FilterReflection; use PHPModelGenerator\Utils\RenderHelper; +use PHPModelGenerator\Utils\TypeCheck; use ReflectionException; -use ReflectionMethod; /** * Class FilterValidator @@ -33,32 +34,32 @@ public function __construct( protected FilterInterface $filter, PropertyInterface $property, protected array $filterOptions = [], - ?TransformingFilterInterface $transformingFilter = null, + private readonly ?TransformingFilterInterface $transformingFilter = null, ) { $this->isResolved = true; - $transformingFilter === null - ? $this->validateFilterCompatibilityWithBaseType($this->filter, $property) - : $this->validateFilterCompatibilityWithTransformedType($this->filter, $transformingFilter, $property); + $acceptedTypes = FilterReflection::getAcceptedTypes($this->filter, $property); + + $this->transformingFilter !== null + ? $this->validateFilterCompatibilityWithTransformedType( + $acceptedTypes, + $this->transformingFilter, + $property, + ) + : $this->runCompatibilityCheck($acceptedTypes, $property); parent::__construct( $property, DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'Filter.phptpl', [ - 'skipTransformedValuesCheck' => $transformingFilter !== null ? '!$transformationFailed' : '', + 'skipTransformedValuesCheck' => $this->transformingFilter !== null ? '!$transformationFailed' : '', 'isTransformingFilter' => $this->filter instanceof TransformingFilterInterface, - // check if the given value has a type matched by the filter - 'typeCheck' => !empty($this->filter->getAcceptedTypes()) - ? '(' . - implode( - ' && ', - array_map( - static fn(string $type): string => - ReflectionTypeCheckValidator::fromType($type, $property)->getCheck(), - $this->mapDataTypes($this->filter->getAcceptedTypes()), - ), - ) . - ')' + // Positive type guard: the filter only executes when the value's runtime type + // matches one of the acceptedTypes. Non-matching values skip the filter entirely + // (the && short-circuits before the filter function is called). + // Empty acceptedTypes means "run for all types" — no guard needed. + 'typeCheck' => !empty($acceptedTypes) + ? TypeCheck::buildCompound($acceptedTypes) : '', 'filterClass' => $this->filter->getFilter()[0], 'filterMethod' => $this->filter->getFilter()[1], @@ -95,50 +96,61 @@ public function getValidatorSetUp(): string * the transforming filter must be executed as they are only compatible with the original value * * @throws ReflectionException + * @throws SchemaException */ public function addTransformedCheck(TransformingFilterInterface $filter, PropertyInterface $property): self { - $typeAfterFilter = (new ReflectionMethod($filter->getFilter()[0], $filter->getFilter()[1]))->getReturnType(); - - if ( - $typeAfterFilter && - $typeAfterFilter->getName() && - !in_array($typeAfterFilter->getName(), $this->mapDataTypes($filter->getAcceptedTypes())) - ) { - $this->templateValues['skipTransformedValuesCheck'] = ReflectionTypeCheckValidator::fromReflectionType( - $typeAfterFilter, - $property, - )->getCheck(); + $returnTypeNames = FilterReflection::getReturnTypeNames($filter, $property); + $acceptedTypes = FilterReflection::getAcceptedTypes($filter, $property); + $nonAccepted = array_values(array_diff($returnTypeNames, $acceptedTypes)); + + if (!empty($nonAccepted)) { + $this->templateValues['skipTransformedValuesCheck'] = TypeCheck::buildNegatedCompound($nonAccepted); } return $this; } /** - * Check if the given filter is compatible with the base type of the property defined in the schema + * Check if the given filter is compatible with the base type of the property defined in the schema. + * + * A filter is compatible when: + * - it accepts all types (empty acceptedTypes derived from callable's first parameter type hint), or + * - the property is untyped (any non-empty acceptedTypes has overlap with the infinite type space), or + * - the property's types have at least one overlap with the filter's acceptedTypes. + * + * Only a complete zero overlap on a typed property is an error, because the filter could never + * execute under any circumstances. Partial overlap is fine: the runtime typeCheck guard in the + * generated code already skips the filter for non-matching value types. + * + * @param string[] $acceptedTypes Pre-computed accepted types of the filter. * * @throws SchemaException */ - private function validateFilterCompatibilityWithBaseType(FilterInterface $filter, PropertyInterface $property): void + private function runCompatibilityCheck(array $acceptedTypes, PropertyInterface $property): void { - if (empty($filter->getAcceptedTypes()) || !$property->getType()) { + if (empty($acceptedTypes)) { return; } - $typeNames = $property->getType()->getNames(); - if ( - ( - !empty($typeNames) && - !empty(array_diff($typeNames, $this->mapDataTypes($filter->getAcceptedTypes()))) - ) || ( - $property->getType()->isNullable() && !in_array('null', $filter->getAcceptedTypes()) - ) - ) { + if ($property->getType() === null && $property->getNestedSchema() === null) { + return; + } + + $typeNames = $property->getNestedSchema() !== null + ? ['object'] + : $property->getType()->getNames(); + $isNullable = $property->getType()?->isNullable() ?? false; + + $hasOverlap = !empty(array_intersect($typeNames, $acceptedTypes)) + || ($isNullable && in_array('null', $acceptedTypes, true)); + + if (!$hasOverlap) { throw new SchemaException( sprintf( 'Filter %s is not compatible with property type %s for property %s in file %s', - $filter->getToken(), - implode('|', $typeNames), + $this->filter->getToken(), + implode('|', array_merge($typeNames, $isNullable ? ['null'] : [])), $property->getName(), $property->getJsonSchema()->getFile(), ) @@ -147,35 +159,67 @@ private function validateFilterCompatibilityWithBaseType(FilterInterface $filter } /** - * Check if the given filter is compatible with the result of the given transformation filter + * Check if the given filter is compatible with the result of the given transformation filter. + * + * All parts of the transformed output (including null when nullable) must be accepted by + * the subsequent filter. Any unhandled return type is an error. + * + * @param string[] $filterAcceptedTypes Pre-computed accepted types of the current filter. * * @throws ReflectionException * @throws SchemaException */ private function validateFilterCompatibilityWithTransformedType( - FilterInterface $filter, + array $filterAcceptedTypes, TransformingFilterInterface $transformingFilter, PropertyInterface $property, ): void { - $transformedType = (new ReflectionMethod( - $transformingFilter->getFilter()[0], - $transformingFilter->getFilter()[1], - ))->getReturnType(); - - if ( - !empty($filter->getAcceptedTypes()) && - ( - !in_array($transformedType->getName(), $this->mapDataTypes($filter->getAcceptedTypes())) || - ($transformedType->allowsNull() && !in_array('null', $filter->getAcceptedTypes())) - ) - ) { + $returnTypeNames = FilterReflection::getReturnTypeNames($transformingFilter, $property); + $returnNullable = FilterReflection::isReturnNullable($transformingFilter); + + if (empty($returnTypeNames) && !$returnNullable) { + // Return type is mixed or null-only — subsequent filter must accept all types. + if (!empty($filterAcceptedTypes)) { + throw new SchemaException( + sprintf( + 'Filter %s is not compatible with the unconstrained output of' + . ' transforming filter %s for property %s in file %s' + . ' (not all types are accepted)', + $this->filter->getToken(), + $transformingFilter->getToken(), + $property->getName(), + $property->getJsonSchema()->getFile(), + ) + ); + } + + return; + } + + if (empty($filterAcceptedTypes)) { + // Next filter accepts everything — always compatible. + return; + } + + // All parts of the return type must be handled by the next filter's accepted types. + $allReturnTypes = $returnNullable + ? array_merge($returnTypeNames, ['null']) + : $returnTypeNames; + $unhandled = array_diff($allReturnTypes, $filterAcceptedTypes); + + if (!empty($unhandled)) { + $displayTypes = $returnNullable + ? array_merge(['null'], $returnTypeNames) + : $returnTypeNames; + $typeDisplay = count($displayTypes) > 1 + ? '[' . implode(', ', $displayTypes) . ']' + : $displayTypes[0]; + throw new SchemaException( sprintf( 'Filter %s is not compatible with transformed property type %s for property %s in file %s', - $filter->getToken(), - $transformedType->allowsNull() - ? "[null, {$transformedType->getName()}]" - : $transformedType->getName(), + $this->filter->getToken(), + $typeDisplay, $property->getName(), $property->getJsonSchema()->getFile(), ) @@ -183,19 +227,6 @@ private function validateFilterCompatibilityWithTransformedType( } } - /** - * Map a list of accepted data types to their corresponding PHP types - */ - private function mapDataTypes(array $acceptedTypes): array - { - return array_map(static fn(string $jsonSchemaType): string => match ($jsonSchemaType) { - 'integer' => 'int', - 'number' => 'float', - 'boolean' => 'bool', - default => $jsonSchemaType, - }, $acceptedTypes); - } - public function getFilter(): FilterInterface { return $this->filter; diff --git a/src/Model/Validator/PassThroughTypeCheckValidator.php b/src/Model/Validator/PassThroughTypeCheckValidator.php index 4f4655d3..76888b01 100644 --- a/src/Model/Validator/PassThroughTypeCheckValidator.php +++ b/src/Model/Validator/PassThroughTypeCheckValidator.php @@ -6,7 +6,7 @@ use PHPModelGenerator\Exception\Generic\InvalidTypeException; use PHPModelGenerator\Model\Property\PropertyInterface; -use ReflectionType; +use PHPModelGenerator\Utils\TypeCheck; /** * Class PassThroughTypeCheckValidator @@ -20,23 +20,25 @@ class PassThroughTypeCheckValidator extends PropertyValidator implements TypeChe /** * PassThroughTypeCheckValidator constructor. + * + * @param string[] $passThroughTypeNames Simple PHP type names of the transformed output + * (e.g. ['DateTime'] or ['DateTime', 'Date', 'Time']) */ public function __construct( - ReflectionType $passThroughType, + array $passThroughTypeNames, PropertyInterface $property, - TypeCheckValidator $typeCheckValidator, + TypeCheckValidator|MultiTypeCheckValidator $typeCheckValidator, ) { - $this->types = array_merge($typeCheckValidator->getTypes(), [$passThroughType->getName()]); + $this->types = array_values(array_unique(array_merge($typeCheckValidator->getTypes(), $passThroughTypeNames))); + + // Condition for throwing: value is neither the transformed type nor the original type. + $passThroughCheck = TypeCheck::buildNegatedCompound($passThroughTypeNames); parent::__construct( $property, - sprintf( - '%s && %s', - ReflectionTypeCheckValidator::fromReflectionType($passThroughType, $property)->getCheck(), - $typeCheckValidator->getCheck(), - ), + sprintf('%s && %s', $passThroughCheck, $typeCheckValidator->getCheck()), InvalidTypeException::class, - [[$passThroughType->getName(), $property->getType()->getNames()[0]]], + [[implode(' | ', $passThroughTypeNames), implode(' | ', $typeCheckValidator->getTypes())]], ); } diff --git a/src/Model/Validator/PatternPropertiesValidator.php b/src/Model/Validator/PatternPropertiesValidator.php index c0cf29bc..e9cd329b 100644 --- a/src/Model/Validator/PatternPropertiesValidator.php +++ b/src/Model/Validator/PatternPropertiesValidator.php @@ -10,9 +10,7 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -39,14 +37,14 @@ public function __construct( ) { $this->key = md5($propertyStructure->getJson()['key'] ?? $this->pattern); - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); + $propertyFactory = new PropertyFactory(); $this->validationProperty = $propertyFactory->create( - new PropertyMetaDataCollection(['pattern property']), $schemaProcessor, $schema, 'pattern property', $propertyStructure, + true, ); $this->validationProperty->onResolve(function (): void { diff --git a/src/Model/Validator/PropertyNamesValidator.php b/src/Model/Validator/PropertyNamesValidator.php index 732e5d42..e4eb5edd 100644 --- a/src/Model/Validator/PropertyNamesValidator.php +++ b/src/Model/Validator/PropertyNamesValidator.php @@ -10,9 +10,7 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator; -use PHPModelGenerator\PropertyProcessor\Property\ConstProcessor; -use PHPModelGenerator\PropertyProcessor\Property\StringProcessor; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; +use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -35,16 +33,32 @@ public function __construct( ) { $this->isResolved = true; - $processor = array_key_exists('const', $propertiesNames->getJson()) - ? ConstProcessor::class - : StringProcessor::class; - - if ($processor === ConstProcessor::class && gettype($propertiesNames->getJson()['const']) !== 'string') { + if ( + array_key_exists('const', $propertiesNames->getJson()) && + gettype($propertiesNames->getJson()['const']) !== 'string' + ) { throw new SchemaException("Invalid const property name in file {$propertiesNames->getFile()}"); } - $nameValidationProperty = (new $processor(new PropertyMetaDataCollection(), $schemaProcessor, $schema)) - ->process('property name', $propertiesNames) + if ( + array_key_exists('type', $propertiesNames->getJson()) && + $propertiesNames->getJson()['type'] !== 'string' + ) { + throw new SchemaException(sprintf( + "Invalid type '%s' for propertyNames schema in file %s", + $propertiesNames->getJson()['type'], + $propertiesNames->getFile(), + )); + } + + // Property names are always strings; ensure the schema declares the type so that + // string-specific validators (minLength, maxLength, pattern) are applied. + $propertiesNamesAsString = array_key_exists('type', $propertiesNames->getJson()) + ? $propertiesNames + : $propertiesNames->withJson(['type' => 'string'] + $propertiesNames->getJson()); + + $nameValidationProperty = (new PropertyFactory()) + ->create($schemaProcessor, $schema, 'property name', $propertiesNamesAsString) // the property name validator doesn't need type checks or required checks so simply filter them out ->filterValidators(static fn(Validator $validator): bool => !is_a($validator->getValidator(), RequiredPropertyValidator::class) && diff --git a/src/Model/Validator/ReflectionTypeCheckValidator.php b/src/Model/Validator/ReflectionTypeCheckValidator.php index 5a8dbbdb..6804031e 100644 --- a/src/Model/Validator/ReflectionTypeCheckValidator.php +++ b/src/Model/Validator/ReflectionTypeCheckValidator.php @@ -5,7 +5,7 @@ namespace PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Property\PropertyInterface; -use ReflectionType; +use PHPModelGenerator\Utils\TypeCheck; /** * Class ReflectionTypeCheckValidator @@ -14,41 +14,19 @@ */ class ReflectionTypeCheckValidator extends PropertyValidator { - public static function fromReflectionType( - ReflectionType $reflectionType, - PropertyInterface $property, - ): self { - return new self( - $reflectionType->isBuiltin(), - $reflectionType->getName(), - $property, - ); - } - public static function fromType( string $type, PropertyInterface $property, ): self { - return new self( - in_array($type, ['int', 'float', 'string', 'bool', 'array', 'object', 'null']), - $type, - $property, - ); + return new self($type, $property); } /** * ReflectionTypeCheckValidator constructor. */ - public function __construct(bool $isBuiltin, string $name, PropertyInterface $property) + public function __construct(string $name, PropertyInterface $property) { - if ($isBuiltin) { - $typeCheck = "!is_{$name}(\$value)"; - } else { - $parts = explode('\\', $name); - $className = end($parts); - - $typeCheck = "!(\$value instanceof $className)"; - } + $typeCheck = TypeCheck::buildNegatedCompound([$name]); parent::__construct($property, $typeCheck, ''); } diff --git a/src/ModelGenerator.php b/src/ModelGenerator.php index 2c0f15b2..02c47ab2 100644 --- a/src/ModelGenerator.php +++ b/src/ModelGenerator.php @@ -15,7 +15,8 @@ CompositionValidationPostProcessor, ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor, PatternPropertiesPostProcessor, - SerializationPostProcessor + SerializationPostProcessor, + TransformingFilterOutputTypePostProcessor }; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; use PHPModelGenerator\SchemaProcessor\RenderQueue; @@ -47,7 +48,8 @@ public function __construct(protected GeneratorConfiguration $generatorConfigura ->addPostProcessor(new AdditionalPropertiesPostProcessor()) ->addPostProcessor(new PatternPropertiesPostProcessor()) ->addPostProcessor(new ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor()) - ->addPostProcessor(new CompositionRequiredPromotionPostProcessor()); + ->addPostProcessor(new CompositionRequiredPromotionPostProcessor()) + ->addPostProcessor(new TransformingFilterOutputTypePostProcessor()); } public function addPostProcessor(PostProcessor $postProcessor): self diff --git a/src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php b/src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php deleted file mode 100644 index a0ac98fe..00000000 --- a/src/PropertyProcessor/ComposedValue/AbstractComposedValueProcessor.php +++ /dev/null @@ -1,247 +0,0 @@ -getJson()['propertySchema']->getJson(); - - if ( - empty($json[$propertySchema->getJson()['type']]) && - $this->schemaProcessor->getGeneratorConfiguration()->isOutputEnabled() - ) { - // @codeCoverageIgnoreStart - echo "Warning: empty composition for {$property->getName()} may lead to unexpected results\n"; - // @codeCoverageIgnoreEnd - } - - $compositionProperties = $this->getCompositionProperties($property, $propertySchema); - - $resolvedCompositions = 0; - foreach ($compositionProperties as $compositionProperty) { - $compositionProperty->onResolve( - function () use (&$resolvedCompositions, $property, $compositionProperties, $propertySchema): void { - if (++$resolvedCompositions === count($compositionProperties)) { - $this->transferPropertyType($property, $compositionProperties); - - $this->mergedProperty = !$this->rootLevelComposition - && $this instanceof MergedComposedPropertiesInterface - ? $this->schemaProcessor->createMergedProperty( - $this->schema, - $property, - $compositionProperties, - $propertySchema, - ) - : null; - } - }, - ); - } - - $availableAmount = count($compositionProperties); - - $property->addValidator( - new ComposedPropertyValidator( - $this->schemaProcessor->getGeneratorConfiguration(), - $property, - $compositionProperties, - static::class, - [ - 'compositionProperties' => $compositionProperties, - 'schema' => $this->schema, - 'generatorConfiguration' => $this->schemaProcessor->getGeneratorConfiguration(), - 'viewHelper' => new RenderHelper($this->schemaProcessor->getGeneratorConfiguration()), - 'availableAmount' => $availableAmount, - 'composedValueValidation' => $this->getComposedValueValidation($availableAmount), - // if the property is a composed property the resulting value of a validation must be proposed - // to be the final value after the validations (e.g. object instantiations may be performed). - // Otherwise (eg. a NotProcessor) the value must be proposed before the validation - 'postPropose' => $this instanceof ComposedPropertiesInterface, - 'mergedProperty' => &$this->mergedProperty, - 'onlyForDefinedValues' => - $propertySchema->getJson()['onlyForDefinedValues'] - && $this instanceof ComposedPropertiesInterface, - ], - ), - 100, - ); - } - - /** - * Set up composition properties for the given property schema - * - * @return CompositionPropertyDecorator[] - * - * @throws SchemaException - */ - protected function getCompositionProperties(PropertyInterface $property, JsonSchema $propertySchema): array - { - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); - $compositionProperties = []; - $json = $propertySchema->getJson()['propertySchema']->getJson(); - - // clear the base type of the property to keep only the types of the composition. - // This avoids e.g. "array|int[]" for a property which is known to contain always an integer array - $property->addTypeHintDecorator(new ClearTypeHintDecorator()); - - foreach ($json[$propertySchema->getJson()['type']] as $compositionElement) { - $compositionSchema = $propertySchema->getJson()['propertySchema']->withJson($compositionElement); - - $compositionProperty = new CompositionPropertyDecorator( - $property->getName(), - $compositionSchema, - $propertyFactory - ->create( - new PropertyMetaDataCollection([$property->getName() => $property->isRequired()]), - $this->schemaProcessor, - $this->schema, - $property->getName(), - $compositionSchema, - ) - ); - - $compositionProperty->onResolve(function () use ($compositionProperty, $property): void { - $compositionProperty->filterValidators( - static fn(Validator $validator): bool => - !is_a($validator->getValidator(), RequiredPropertyValidator::class) && - !is_a($validator->getValidator(), ComposedPropertyValidator::class) - ); - - // only create a composed type hint if we aren't a AnyOf or an AllOf processor and the - // compositionProperty contains no object. This results in objects being composed each separately for a - // OneOf processor (e.g. string|ObjectA|ObjectB). For a merged composed property the objects are merged - // together, so it results in string|MergedObject - if (!($this instanceof MergedComposedPropertiesInterface && $compositionProperty->getNestedSchema())) { - $property->addTypeHintDecorator(new CompositionTypeHintDecorator($compositionProperty)); - } - }); - - $compositionProperties[] = $compositionProperty; - } - - return $compositionProperties; - } - - /** - * Check if the provided property can inherit a single type from the composition properties. - * - * @param CompositionPropertyDecorator[] $compositionProperties - */ - private function transferPropertyType(PropertyInterface $property, array $compositionProperties): void - { - if ($this instanceof NotProcessor) { - return; - } - - // Skip widening when any branch has a nested schema (object): the merged-property - // mechanism creates a combined class whose name is not among the per-branch type names. - foreach ($compositionProperties as $compositionProperty) { - if ($compositionProperty->getNestedSchema() !== null) { - return; - } - } - - // Flatten all type names from all branches. Use getNames() to handle branches that - // already carry a union PropertyType. - $allNames = array_merge(...array_map( - static fn(CompositionPropertyDecorator $compositionProperty): array => - $compositionProperty->getType() ? $compositionProperty->getType()->getNames() : [], - $compositionProperties, - )); - - // A branch with no type contributes nothing but signals that nullable=true is required. - $hasBranchWithNoType = array_filter( - $compositionProperties, - static fn(CompositionPropertyDecorator $compositionProperty): bool => - $compositionProperty->getType() === null, - ) !== []; - - // An optional branch (property not required in that branch) means the property can be - // absent at runtime, causing the root getter to return null. This is a structural - // nullable — independent of the implicit-null configuration setting. - // - // For oneOf/anyOf: any optional branch makes the property nullable (the branch that - // omits the property can match, leaving the value as null). - // - // For allOf: all branches must hold simultaneously. If at least one branch marks the - // property as required, the property is required overall — an optional branch in allOf - // does not by itself make the property nullable. Only if NO branch requires the property - // (i.e. the property is optional across all allOf branches) is it structurally nullable. - $hasBranchWithRequiredProperty = array_filter( - $compositionProperties, - static fn(CompositionPropertyDecorator $compositionProperty): bool => - $compositionProperty->isRequired(), - ) !== []; - $hasBranchWithOptionalProperty = $this instanceof AllOfProcessor - ? !$hasBranchWithRequiredProperty - : array_filter( - $compositionProperties, - static fn(CompositionPropertyDecorator $compositionProperty): bool => - !$compositionProperty->isRequired(), - ) !== []; - - // Strip 'null' → nullable flag; PropertyType constructor deduplicates the rest. - $hasNull = in_array('null', $allNames, true); - $nonNullNames = array_values(array_filter( - array_unique($allNames), - fn(string $t): bool => $t !== 'null', - )); - - if (!$nonNullNames) { - return; - } - - $nullable = ($hasNull || $hasBranchWithNoType || $hasBranchWithOptionalProperty) ? true : null; - - $property->setType(new PropertyType($nonNullNames, $nullable)); - } - - /** - * @param int $composedElements The amount of elements which are composed together - */ - abstract protected function getComposedValueValidation(int $composedElements): string; -} diff --git a/src/PropertyProcessor/ComposedValue/AllOfProcessor.php b/src/PropertyProcessor/ComposedValue/AllOfProcessor.php deleted file mode 100644 index aea3321f..00000000 --- a/src/PropertyProcessor/ComposedValue/AllOfProcessor.php +++ /dev/null @@ -1,23 +0,0 @@ - 0"; - } -} diff --git a/src/PropertyProcessor/ComposedValue/ComposedPropertiesInterface.php b/src/PropertyProcessor/ComposedValue/ComposedPropertiesInterface.php deleted file mode 100644 index daa590b0..00000000 --- a/src/PropertyProcessor/ComposedValue/ComposedPropertiesInterface.php +++ /dev/null @@ -1,14 +0,0 @@ -getJson()['propertySchema']->getJson(); - - if (!isset($json['then']) && !isset($json['else'])) { - throw new SchemaException( - sprintf( - 'Incomplete conditional composition for property %s in file %s', - $property->getName(), - $property->getJsonSchema()->getFile(), - ) - ); - } - - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); - - $properties = []; - - foreach (['if', 'then', 'else'] as $compositionElement) { - if (!isset($json[$compositionElement])) { - $properties[$compositionElement] = null; - continue; - } - - $compositionSchema = $propertySchema->getJson()['propertySchema']->withJson($json[$compositionElement]); - - $compositionProperty = new CompositionPropertyDecorator( - $property->getName(), - $compositionSchema, - $propertyFactory - ->create( - new PropertyMetaDataCollection([$property->getName() => $property->isRequired()]), - $this->schemaProcessor, - $this->schema, - $property->getName(), - $compositionSchema, - ) - ); - - $compositionProperty->onResolve(static function () use ($compositionProperty): void { - $compositionProperty->filterValidators(static fn(Validator $validator): bool => - !is_a($validator->getValidator(), RequiredPropertyValidator::class) && - !is_a($validator->getValidator(), ComposedPropertyValidator::class),); - }); - - $properties[$compositionElement] = $compositionProperty; - } - - $property->addValidator( - new ConditionalPropertyValidator( - $this->schemaProcessor->getGeneratorConfiguration(), - $property, - array_filter($properties), - array_filter([$properties['then'], $properties['else']]), - [ - 'ifProperty' => $properties['if'], - 'thenProperty' => $properties['then'], - 'elseProperty' => $properties['else'], - 'schema' => $this->schema, - 'generatorConfiguration' => $this->schemaProcessor->getGeneratorConfiguration(), - 'viewHelper' => new RenderHelper($this->schemaProcessor->getGeneratorConfiguration()), - 'onlyForDefinedValues' => $propertySchema->getJson()['onlyForDefinedValues'], - ], - ), - 100, - ); - - //parent::generateValidators($property, $propertySchema); - } -} diff --git a/src/PropertyProcessor/ComposedValue/MergedComposedPropertiesInterface.php b/src/PropertyProcessor/ComposedValue/MergedComposedPropertiesInterface.php deleted file mode 100644 index e4669e81..00000000 --- a/src/PropertyProcessor/ComposedValue/MergedComposedPropertiesInterface.php +++ /dev/null @@ -1,14 +0,0 @@ -getJson()['propertySchema']->getJson(); - - // as the not composition only takes one schema nest it one level deeper to use the ComposedValueProcessor - $json['not'] = [$json['not']]; - - // strict type checks for not constraint to avoid issues with null - $property->setRequired(true); - parent::generateValidators( - $property, - $propertySchema->withJson( - array_merge( - $propertySchema->getJson(), - ['propertySchema' => $propertySchema->getJson()['propertySchema']->withJson($json)], - ) - ), - ); - } - - /** - * @inheritdoc - */ - protected function getComposedValueValidation(int $composedElements): string - { - return '$succeededCompositionElements === 0'; - } -} diff --git a/src/PropertyProcessor/ComposedValue/OneOfProcessor.php b/src/PropertyProcessor/ComposedValue/OneOfProcessor.php deleted file mode 100644 index b8602344..00000000 --- a/src/PropertyProcessor/ComposedValue/OneOfProcessor.php +++ /dev/null @@ -1,21 +0,0 @@ -rootLevelComposition; - } - - return new $processor(...$params); - } -} diff --git a/src/PropertyProcessor/Filter/DateTimeFilter.php b/src/PropertyProcessor/Filter/DateTimeFilter.php index 67d529d6..23050a15 100644 --- a/src/PropertyProcessor/Filter/DateTimeFilter.php +++ b/src/PropertyProcessor/Filter/DateTimeFilter.php @@ -16,14 +16,6 @@ */ class DateTimeFilter implements TransformingFilterInterface { - /** - * @inheritDoc - */ - public function getAcceptedTypes(): array - { - return ['integer', 'string', 'number', 'null']; - } - /** * @inheritDoc */ diff --git a/src/PropertyProcessor/Filter/FilterProcessor.php b/src/PropertyProcessor/Filter/FilterProcessor.php index 53fdf81f..fb5e9dce 100644 --- a/src/PropertyProcessor/Filter/FilterProcessor.php +++ b/src/PropertyProcessor/Filter/FilterProcessor.php @@ -15,14 +15,14 @@ use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Validator\EnumValidator; use PHPModelGenerator\Model\Validator\FilterValidator; +use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator; use PHPModelGenerator\Model\Validator\PassThroughTypeCheckValidator; use PHPModelGenerator\Model\Validator\PropertyValidator; -use PHPModelGenerator\Model\Validator\ReflectionTypeCheckValidator; use PHPModelGenerator\Model\Validator\TypeCheckValidator; +use PHPModelGenerator\Utils\FilterReflection; use PHPModelGenerator\Utils\RenderHelper; +use PHPModelGenerator\Utils\TypeCheck; use ReflectionException; -use ReflectionMethod; -use ReflectionType; /** * Class FilterProcessor @@ -96,12 +96,9 @@ public function process( } } - $property->addValidator( - new FilterValidator($generatorConfiguration, $filter, $property, $filterOptions, $transformingFilter), - $filterPriority++, - ); + $isTransformingFilter = $filter instanceof TransformingFilterInterface; - if ($filter instanceof TransformingFilterInterface) { + if ($isTransformingFilter) { if ($property->getType() && in_array('array', $property->getType()->getNames(), true)) { throw new SchemaException( sprintf( @@ -120,97 +117,137 @@ public function process( ) ); } + } - // keep track of the transforming filter to modify type checks for following filters - $transformingFilter = $filter; + // $transformingFilter is still null here when the current filter IS the transforming + // filter — FilterValidator correctly receives null (no previous transforming filter). + $property->addValidator( + new FilterValidator($generatorConfiguration, $filter, $property, $filterOptions, $transformingFilter), + $filterPriority++, + ); - $typeAfterFilter = (new ReflectionMethod($filter->getFilter()[0], $filter->getFilter()[1])) - ->getReturnType(); - - if ( - $typeAfterFilter && - $typeAfterFilter->getName() && - (!$property->getType() || - !in_array($typeAfterFilter->getName(), $property->getType()->getNames(), true)) - ) { - $this->addTransformedValuePassThrough($property, $filter, $typeAfterFilter); - $this->extendTypeCheckValidatorToAllowTransformedValue($property, $typeAfterFilter); - - $property->setType( - $property->getType(), - new PropertyType( - (new RenderHelper($generatorConfiguration)) - ->getSimpleClassName($typeAfterFilter->getName()), - $typeAfterFilter->allowsNull(), - ) - ); + if ($isTransformingFilter) { + $returnTypeNames = FilterReflection::getReturnTypeNames($filter, $property); + + if (!empty($returnTypeNames)) { + // Wire pass-through checks on pre-transforming FilterValidators/EnumValidators + // so they are skipped when an already-transformed value is provided. + // Only validators present at this point (i.e. before the transforming filter) + // receive the check — post-transform validators are added later and use + // !$transformationFailed instead. + $this->addTransformedValuePassThrough($property, $filter, $returnTypeNames); - if (!$typeAfterFilter->isBuiltin()) { - $schema->addUsedClass($typeAfterFilter->getName()); + // Eagerly set the output type when the base type is already known. + // This preserves the output type through property cloning in merged composition + // schemas (where validators are stripped but the type fields are retained). + // When the base type is null (type comes from a sibling allOf branch), this is + // skipped and TransformingFilterOutputTypePostProcessor handles it after + // composition has resolved the final base type. + $baseType = $property->getType(); + if ($baseType !== null) { + $this->applyOutputType( + $property, + $filter, + $returnTypeNames, + $baseType, + $generatorConfiguration, + $schema, + ); } } + + $transformingFilter = $filter; } } } /** - * Apply a check to each FilterValidator which is already associated with the given property to pass through values - * which are already transformed. - * By adding the pass through eg. a trim filter executed before a dateTime transforming filter will not be executed - * if a DateTime object is provided for the property + * Compute the output type using the bypass formula and apply it to the property. + * + * Formula: + * accepted = filter callable's first-parameter types ([] = accepts all) + * bypass_names = base_names − non-null accepted ([] when accepted is empty) + * bypass_nullable = base_nullable AND 'null' NOT in accepted (false when accepted is empty) + * output_names = bypass_names ∪ return_type_names + * output_nullable = bypass_nullable OR return_nullable + * + * @param string[] $returnTypeNames Non-null return type names of the transforming filter. * * @throws ReflectionException + * @throws SchemaException */ - private function addTransformedValuePassThrough( + public function applyOutputType( PropertyInterface $property, TransformingFilterInterface $filter, - ReflectionType $filteredType, + array $returnTypeNames, + PropertyType $baseType, + GeneratorConfiguration $generatorConfiguration, + Schema $schema, ): void { - foreach ($property->getValidators() as $validator) { - $validator = $validator->getValidator(); + $returnNullable = FilterReflection::isReturnNullable($filter); + $acceptedTypes = FilterReflection::getAcceptedTypes($filter, $property); - if ($validator instanceof FilterValidator) { - $validator->addTransformedCheck($filter, $property); - } + if (empty($acceptedTypes)) { + $bypassNames = []; + $bypassNullable = false; + } else { + $nonNullAccepted = array_values( + array_filter($acceptedTypes, static fn(string $type): bool => $type !== 'null'), + ); + $hasNullAccepted = in_array('null', $acceptedTypes, true); + $bypassNames = array_values(array_diff($baseType->getNames(), $nonNullAccepted)); + $bypassNullable = ($baseType->isNullable() === true) && !$hasNullAccepted; + } - if ($validator instanceof EnumValidator) { - $property->filterValidators( - static fn(Validator $validator): bool => !is_a($validator->getValidator(), EnumValidator::class), - ); + $baseNames = $baseType->getNames(); + $newReturnTypeNames = array_values(array_diff($returnTypeNames, $baseNames)); - // shift the name from the validator to avoid adding it twice by wrapping the validator into another one - $exceptionParams = $validator->getExceptionParams(); - array_shift($exceptionParams); + if (empty($newReturnTypeNames)) { + return; + } - $property->addValidator( - new PropertyValidator( - $property, - sprintf( - "%s && %s", - ReflectionTypeCheckValidator::fromReflectionType($filteredType, $property)->getCheck(), - $validator->getCheck(), - ), - $validator->getExceptionClass(), - $exceptionParams, - ), - 3, - ); + $outputNames = array_values(array_unique(array_merge($bypassNames, $returnTypeNames))); + $outputNullable = $bypassNullable || $returnNullable; + + $renderHelper = new RenderHelper($generatorConfiguration); + $outputTypeNames = array_map( + static fn(string $name): string => $renderHelper->getSimpleClassName($name), + $outputNames, + ); + + $property->setType( + $property->getType(), + new PropertyType($outputTypeNames, $outputNullable), + ); + + foreach ($returnTypeNames as $typeName) { + if (!TypeCheck::isPrimitive($typeName)) { + $schema->addUsedClass($typeName); } } } /** - * Extend a type check of the given property so the type check also allows the type of $typeAfterFilter. This is - * used to allow also already transformed values as valid input values + * Replace the property's TypeCheckValidator / MultiTypeCheckValidator with a + * PassThroughTypeCheckValidator that also allows the given pass-through type names. + * + * When called a second time, the TypeCheckValidator has already been replaced by a + * PassThroughTypeCheckValidator, which does not match the filter predicate, so the call + * is silently skipped. + * + * @param string[] $passThroughTypeNames */ - private function extendTypeCheckValidatorToAllowTransformedValue( + public function extendTypeCheckValidatorToAllowTransformedValue( PropertyInterface $property, - ReflectionType $typeAfterFilter, + array $passThroughTypeNames, ): void { $typeCheckValidator = null; $property->filterValidators(static function (Validator $validator) use (&$typeCheckValidator): bool { - if (is_a($validator->getValidator(), TypeCheckValidator::class)) { + if ( + is_a($validator->getValidator(), TypeCheckValidator::class) || + is_a($validator->getValidator(), MultiTypeCheckValidator::class) + ) { $typeCheckValidator = $validator->getValidator(); return false; } @@ -218,13 +255,60 @@ private function extendTypeCheckValidatorToAllowTransformedValue( return true; }); - if ($typeCheckValidator instanceof TypeCheckValidator) { - // add a combined validator which checks for the transformed value or the original type of the property as a - // replacement for the removed TypeCheckValidator + if ( + $typeCheckValidator instanceof TypeCheckValidator + || $typeCheckValidator instanceof MultiTypeCheckValidator + ) { $property->addValidator( - new PassThroughTypeCheckValidator($typeAfterFilter, $property, $typeCheckValidator), + new PassThroughTypeCheckValidator($passThroughTypeNames, $property, $typeCheckValidator), 2, ); } } + + /** + * Apply a pass-through check to each FilterValidator and EnumValidator already associated + * with the given property so that pre-transform filters and enum checks are skipped when + * an already-transformed value is provided. + * + * @param string[] $returnTypeNames Non-null return type names of the transforming filter. + */ + public function addTransformedValuePassThrough( + PropertyInterface $property, + TransformingFilterInterface $filter, + array $returnTypeNames, + ): void { + foreach ($property->getValidators() as $propertyValidator) { + $validator = $propertyValidator->getValidator(); + + if ($validator instanceof FilterValidator) { + $validator->addTransformedCheck($filter, $property); + } + + if ($validator instanceof EnumValidator) { + $property->filterValidators( + static fn(Validator $enumCandidate): bool => + !is_a($enumCandidate->getValidator(), EnumValidator::class), + ); + + // Shift the name from the validator to avoid adding it twice by wrapping it. + $exceptionParams = $validator->getExceptionParams(); + array_shift($exceptionParams); + + $property->addValidator( + new PropertyValidator( + $property, + sprintf( + '%s && %s', + TypeCheck::buildNegatedCompound($returnTypeNames), + $validator->getCheck(), + ), + $validator->getExceptionClass(), + $exceptionParams, + ), + 3, + ); + } + } + } } diff --git a/src/PropertyProcessor/Filter/NotEmptyFilter.php b/src/PropertyProcessor/Filter/NotEmptyFilter.php index 777202b1..72157042 100644 --- a/src/PropertyProcessor/Filter/NotEmptyFilter.php +++ b/src/PropertyProcessor/Filter/NotEmptyFilter.php @@ -16,14 +16,6 @@ */ class NotEmptyFilter implements FilterInterface { - /** - * @inheritDoc - */ - public function getAcceptedTypes(): array - { - return ['array', 'null']; - } - /** * @inheritDoc */ diff --git a/src/PropertyProcessor/Filter/TrimFilter.php b/src/PropertyProcessor/Filter/TrimFilter.php index e9e6fb3b..b261a20c 100644 --- a/src/PropertyProcessor/Filter/TrimFilter.php +++ b/src/PropertyProcessor/Filter/TrimFilter.php @@ -16,14 +16,6 @@ */ class TrimFilter implements FilterInterface { - /** - * @inheritDoc - */ - public function getAcceptedTypes(): array - { - return ['string', 'null']; - } - /** * @inheritDoc */ diff --git a/src/PropertyProcessor/ProcessorFactoryInterface.php b/src/PropertyProcessor/ProcessorFactoryInterface.php deleted file mode 100644 index 2484dada..00000000 --- a/src/PropertyProcessor/ProcessorFactoryInterface.php +++ /dev/null @@ -1,26 +0,0 @@ -addRangeValidator($property, $propertySchema, self::JSON_FIELD_MINIMUM, '<', MinimumException::class); - $this->addRangeValidator($property, $propertySchema, self::JSON_FIELD_MAXIMUM, '>', MaximumException::class); - - $this->addRangeValidator( - $property, - $propertySchema, - self::JSON_FIELD_MINIMUM_EXCLUSIVE, - '<=', - ExclusiveMinimumException::class, - ); - - $this->addRangeValidator( - $property, - $propertySchema, - self::JSON_FIELD_MAXIMUM_EXCLUSIVE, - '>=', - ExclusiveMaximumException::class, - ); - - $this->addMultipleOfValidator($property, $propertySchema); - } - - /** - * Adds a range validator to the property - * - * @param PropertyInterface $property The property which shall be validated - * @param JsonSchema $propertySchema The schema for the property - * @param string $field Which field of the property data provides the validation value - * @param string $check The check to execute (eg. '<', '>') - * @param string $exceptionClass The exception class for the validation - */ - protected function addRangeValidator( - PropertyInterface $property, - JsonSchema $propertySchema, - string $field, - string $check, - string $exceptionClass, - ): void { - $json = $propertySchema->getJson(); - - if (!isset($json[$field])) { - return; - } - - $property->addValidator( - new PropertyValidator( - $property, - $this->getTypeCheck() . "\$value $check {$json[$field]}", - $exceptionClass, - [$json[$field]], - ) - ); - } - - /** - * Adds a multiple of validator to the property - */ - protected function addMultipleOfValidator(PropertyInterface $property, JsonSchema $propertySchema) - { - $json = $propertySchema->getJson(); - - if (!isset($json[self::JSON_FIELD_MULTIPLE])) { - return; - } - - $property->addValidator( - new PropertyValidator( - $property, - // type unsafe comparison to be compatible with int and float - $json[self::JSON_FIELD_MULTIPLE] == 0 - ? $this->getTypeCheck() . '$value != 0' - : ( - static::TYPE === 'int' - ? $this->getTypeCheck() . "\$value % {$json[self::JSON_FIELD_MULTIPLE]} != 0" - : $this->getTypeCheck() . "fmod(\$value, {$json[self::JSON_FIELD_MULTIPLE]}) != 0" - ), - MultipleOfException::class, - [$json[self::JSON_FIELD_MULTIPLE]], - ), - ); - } -} diff --git a/src/PropertyProcessor/Property/AbstractPropertyProcessor.php b/src/PropertyProcessor/Property/AbstractPropertyProcessor.php deleted file mode 100644 index c562df10..00000000 --- a/src/PropertyProcessor/Property/AbstractPropertyProcessor.php +++ /dev/null @@ -1,298 +0,0 @@ -propertyMetaDataCollection->getAttributeDependencies($property->getName())) { - $this->addDependencyValidator($property, $dependencies); - } - - if ($property->isRequired() && !str_starts_with($property->getName(), 'item of array ')) { - $property->addValidator(new RequiredPropertyValidator($property), 1); - } - - if (isset($propertySchema->getJson()['enum'])) { - $this->addEnumValidator($property, $propertySchema->getJson()['enum']); - } - - $this->addComposedValueValidator($property, $propertySchema); - } - - /** - * Add a validator to a property which validates the value against a list of allowed values - * - * @throws SchemaException - */ - protected function addEnumValidator(PropertyInterface $property, array $allowedValues): void - { - if (empty($allowedValues)) { - throw new SchemaException( - sprintf( - "Empty enum property %s in file %s", - $property->getName(), - $property->getJsonSchema()->getFile(), - ) - ); - } - - $allowedValues = array_unique($allowedValues); - - if (array_key_exists('default', $property->getJsonSchema()->getJson())) { - if (!in_array($property->getJsonSchema()->getJson()['default'], $allowedValues, true)) { - throw new SchemaException( - sprintf( - "Invalid default value %s for enum property %s in file %s", - var_export($property->getJsonSchema()->getJson()['default'], true), - $property->getName(), - $property->getJsonSchema()->getFile(), - ), - ); - } - } - - // no type information provided - inherit the types from the enum values - if (!$property->getType()) { - $typesOfEnum = array_unique(array_map( - static fn($value): string => TypeConverter::gettypeToInternal(gettype($value)), - $allowedValues, - )); - - if (count($typesOfEnum) === 1) { - $property->setType(new PropertyType($typesOfEnum[0])); - } else { - // Multiple types: set a union PropertyType so the native PHP type hint path can - // emit e.g. string|int instead of falling back to no hint at all. - // 'NULL' must be expressed as nullable=true rather than kept as a type name. - $hasNull = in_array('null', $typesOfEnum, true); - $nonNullTypes = array_values(array_filter( - $typesOfEnum, - static fn(string $t): bool => $t !== 'null', - )); - - if ($nonNullTypes) { - $propertyType = new PropertyType($nonNullTypes, $hasNull ? true : null); - $property->setType($propertyType, $propertyType); - } - } - $property->addTypeHintDecorator(new TypeHintDecorator($typesOfEnum)); - } - - if ($this->isImplicitNullAllowed($property) && !in_array(null, $allowedValues, true)) { - $allowedValues[] = null; - } - - $property->addValidator(new EnumValidator($property, $allowedValues), 3); - } - - /** - * @throws SchemaException - */ - protected function addDependencyValidator(PropertyInterface $property, array $dependencies): void - { - // check if we have a simple list of properties which must be present if the current property is present - $propertyDependency = true; - - array_walk( - $dependencies, - static function ($dependency, $index) use (&$propertyDependency): void { - $propertyDependency = $propertyDependency && is_int($index) && is_string($dependency); - }, - ); - - if ($propertyDependency) { - $property->addValidator(new PropertyDependencyValidator($property, $dependencies)); - - return; - } - - if (!isset($dependencies['type'])) { - $dependencies['type'] = 'object'; - } - - $dependencySchema = $this->schemaProcessor->processSchema( - new JsonSchema($this->schema->getJsonSchema()->getFile(), $dependencies), - $this->schema->getClassPath(), - "{$this->schema->getClassName()}_{$property->getName()}_Dependency", - $this->schema->getSchemaDictionary(), - ); - - $property->addValidator(new SchemaDependencyValidator($this->schemaProcessor, $property, $dependencySchema)); - $this->schema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($dependencySchema)); - - $this->transferDependentPropertiesToBaseSchema($dependencySchema); - } - - /** - * Transfer all properties from $dependencySchema to the base schema of the current property - */ - private function transferDependentPropertiesToBaseSchema(Schema $dependencySchema): void - { - foreach ($dependencySchema->getProperties() as $property) { - $this->schema->addProperty( - // validators and types must not be transferred as any value is acceptable for the property if the - // property defining the dependency isn't present - (clone $property) - ->setRequired(false) - ->setType(null) - ->filterValidators(static fn(): bool => false), - ); - } - } - - /** - * @throws SchemaException - */ - protected function addComposedValueValidator(PropertyInterface $property, JsonSchema $propertySchema): void - { - // For non-root object-type properties, composition keywords are processed in full - // inside the nested schema by ObjectProcessor (via processSchema with rootLevelComposition=true). - // Adding a composition validator here would duplicate the validation at the parent level: - // by the time this validator runs, $value is already an instantiated object, so branch - // instanceof checks against branch-specific classes fail, rejecting valid input. - // It would also inject a _Merged_ class name into the type hint, overriding the correct type. - if (!($property instanceof BaseProperty) && ($propertySchema->getJson()['type'] ?? '') === 'object') { - return; - } - - $composedValueKeywords = ['allOf', 'anyOf', 'oneOf', 'not', 'if']; - $propertyFactory = new PropertyFactory(new ComposedValueProcessorFactory($property instanceof BaseProperty)); - - foreach ($composedValueKeywords as $composedValueKeyword) { - if (!isset($propertySchema->getJson()[$composedValueKeyword])) { - continue; - } - - $propertySchema = $this->inheritPropertyType($propertySchema, $composedValueKeyword); - - $composedProperty = $propertyFactory - ->create( - $this->propertyMetaDataCollection, - $this->schemaProcessor, - $this->schema, - $property->getName(), - $propertySchema->withJson([ - 'type' => $composedValueKeyword, - 'propertySchema' => $propertySchema, - 'onlyForDefinedValues' => !($this instanceof BaseProcessor) && - (!$property->isRequired() - && $this->schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()), - ]), - ); - - foreach ($composedProperty->getValidators() as $validator) { - $property->addValidator($validator->getValidator(), $validator->getPriority()); - } - - $property->addTypeHintDecorator(new TypeHintTransferDecorator($composedProperty)); - - if (!$property->getType() && $composedProperty->getType()) { - $property->setType($composedProperty->getType(), $composedProperty->getType(true)); - } - } - } - - /** - * If the type of a property containing a composition is defined outside of the composition make sure each - * composition which doesn't define a type inherits the type - */ - protected function inheritPropertyType(JsonSchema $propertySchema, string $composedValueKeyword): JsonSchema - { - $json = $propertySchema->getJson(); - - if (!isset($json['type'])) { - return $propertySchema; - } - - if ($json['type'] === 'base') { - $json['type'] = 'object'; - } - - switch ($composedValueKeyword) { - case 'not': - if (!isset($json[$composedValueKeyword]['type'])) { - $json[$composedValueKeyword]['type'] = $json['type']; - } - break; - case 'if': - return $this->inheritIfPropertyType($propertySchema->withJson($json)); - default: - foreach ($json[$composedValueKeyword] as &$composedElement) { - if (!isset($composedElement['type'])) { - $composedElement['type'] = $json['type']; - } - } - } - - return $propertySchema->withJson($json); - } - - /** - * Inherit the type of a property into all composed components of a conditional composition - */ - protected function inheritIfPropertyType(JsonSchema $propertySchema): JsonSchema - { - $json = $propertySchema->getJson(); - - foreach (['if', 'then', 'else'] as $composedValueKeyword) { - if (!isset($json[$composedValueKeyword])) { - continue; - } - - if (!isset($json[$composedValueKeyword]['type'])) { - $json[$composedValueKeyword]['type'] = $json['type']; - } - } - - return $propertySchema->withJson($json); - } - - /** - * Check if implicit null values are allowed for the given property (a not required property which has no - * explicit null type and is passed with a null value will be accepted) - */ - protected function isImplicitNullAllowed(PropertyInterface $property): bool - { - return $this->schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() && !$property->isRequired(); - } -} diff --git a/src/PropertyProcessor/Property/AbstractTypedValueProcessor.php b/src/PropertyProcessor/Property/AbstractTypedValueProcessor.php deleted file mode 100644 index 92c1e833..00000000 --- a/src/PropertyProcessor/Property/AbstractTypedValueProcessor.php +++ /dev/null @@ -1,94 +0,0 @@ -getJson()['default'])) { - $this->setDefaultValue($property, $propertySchema->getJson()['default'], $propertySchema); - } - - return $property; - } - - /** - * @throws SchemaException - */ - public function setDefaultValue(PropertyInterface $property, mixed $default, JsonSchema $propertySchema): void - { - // allow integer default values for Number properties - if ($this instanceof NumberProcessor && is_int($default)) { - $default = (float) $default; - } - - if (!$this->getTypeCheckFunction()($default)) { - throw new SchemaException( - sprintf( - "Invalid type for default value of property %s in file %s", - $property->getName(), - $propertySchema->getFile(), - ) - ); - } - - $property->setDefaultValue($default); - } - - /** - * @inheritdoc - */ - protected function generateValidators(PropertyInterface $property, JsonSchema $propertySchema): void - { - parent::generateValidators($property, $propertySchema); - - $property->addValidator( - new TypeCheckValidator(static::TYPE, $property, $this->isImplicitNullAllowed($property)), - 2, - ); - } - - protected function getTypeCheck(): string - { - return $this->getTypeCheckFunction() . '($value) && '; - } - - private function getTypeCheckFunction(): string - { - return 'is_' . strtolower(static::TYPE); - } -} diff --git a/src/PropertyProcessor/Property/AbstractValueProcessor.php b/src/PropertyProcessor/Property/AbstractValueProcessor.php deleted file mode 100644 index 4b4d03ff..00000000 --- a/src/PropertyProcessor/Property/AbstractValueProcessor.php +++ /dev/null @@ -1,72 +0,0 @@ -getJson(); - - $property = (new Property( - $propertyName, - $this->type ? new PropertyType($this->type) : null, - $propertySchema, - $json['description'] ?? '', - )) - ->setRequired($this->propertyMetaDataCollection->isAttributeRequired($propertyName)) - ->setReadOnly( - (isset($json['readOnly']) && $json['readOnly'] === true) || - $this->schemaProcessor->getGeneratorConfiguration()->isImmutable(), - ); - - $this->generateValidators($property, $propertySchema); - - if (isset($json['filter'])) { - (new FilterProcessor())->process( - $property, - $json['filter'], - $this->schemaProcessor->getGeneratorConfiguration(), - $this->schema, - ); - } - - return $property; - } -} diff --git a/src/PropertyProcessor/Property/AnyProcessor.php b/src/PropertyProcessor/Property/AnyProcessor.php deleted file mode 100644 index 1b797321..00000000 --- a/src/PropertyProcessor/Property/AnyProcessor.php +++ /dev/null @@ -1,27 +0,0 @@ -getJson()['default'])) { - $property->setDefaultValue($propertySchema->getJson()['default']); - } - - return $property; - } -} diff --git a/src/PropertyProcessor/Property/ArrayProcessor.php b/src/PropertyProcessor/Property/ArrayProcessor.php deleted file mode 100644 index 972f59f0..00000000 --- a/src/PropertyProcessor/Property/ArrayProcessor.php +++ /dev/null @@ -1,263 +0,0 @@ -addLengthValidation($property, $propertySchema); - $this->addUniqueItemsValidation($property, $propertySchema); - $this->addItemsValidation($property, $propertySchema); - $this->addContainsValidation($property, $propertySchema); - - if ( - !$property->isRequired() && - $this->schemaProcessor->getGeneratorConfiguration()->isDefaultArraysToEmptyArrayEnabled() - ) { - $property->addDecorator(new DefaultArrayToEmptyArrayDecorator()); - - if ($property->getType()) { - $property->setType( - $property->getType(), - new PropertyType($property->getType(true)->getNames(), false), - ); - } - - if (!$property->getDefaultValue()) { - $property->setDefaultValue([]); - } - } - } - - /** - * Add the vaidation for the allowed amount of items in the array - */ - private function addLengthValidation(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (isset($json[self::JSON_FIELD_MIN_ITEMS])) { - $property->addValidator( - new PropertyValidator( - $property, - $this->getTypeCheck() . "count(\$value) < {$json[self::JSON_FIELD_MIN_ITEMS]}", - MinItemsException::class, - [$json[self::JSON_FIELD_MIN_ITEMS]], - ) - ); - } - - if (isset($json[self::JSON_FIELD_MAX_ITEMS])) { - $property->addValidator( - new PropertyValidator( - $property, - $this->getTypeCheck() . "count(\$value) > {$json[self::JSON_FIELD_MAX_ITEMS]}", - MaxItemsException::class, - [$json[self::JSON_FIELD_MAX_ITEMS]], - ) - ); - } - } - - /** - * Add the validator to check if the items inside an array are unique - */ - private function addUniqueItemsValidation(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!isset($json['uniqueItems']) || $json['uniqueItems'] !== true) { - return; - } - - $property->addValidator( - new PropertyTemplateValidator( - $property, - DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ArrayUnique.phptpl', - [], - UniqueItemsException::class, - ) - ); - } - - /** - * Add the validator to check for constraints required for each item - * - * @throws FileSystemException - * @throws SchemaException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - private function addItemsValidation(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!isset($json[self::JSON_FIELD_ITEMS])) { - return; - } - - // check if the items require a tuple validation - if ( - is_array($json[self::JSON_FIELD_ITEMS]) && - array_keys($json[self::JSON_FIELD_ITEMS]) === range(0, count($json[self::JSON_FIELD_ITEMS]) - 1) - ) { - $this->addTupleValidator($property, $propertySchema); - - return; - } - - $property->addValidator( - new ArrayItemValidator( - $this->schemaProcessor, - $this->schema, - $propertySchema->withJson($json[self::JSON_FIELD_ITEMS]), - $property, - ) - ); - } - - /** - * Add the validator to check a tuple validation for each item of the array - * - * @throws SchemaException - * @throws FileSystemException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - private function addTupleValidator(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (isset($json['additionalItems']) && $json['additionalItems'] !== true) { - $this->addAdditionalItemsValidator($property, $propertySchema); - } - - $property->addValidator( - new ArrayTupleValidator( - $this->schemaProcessor, - $this->schema, - $propertySchema->withJson($json[self::JSON_FIELD_ITEMS]), - $property->getName(), - ) - ); - } - - /** - * @throws FileSystemException - * @throws SchemaException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - private function addAdditionalItemsValidator(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!is_bool($json['additionalItems'])) { - $property->addValidator( - new AdditionalItemsValidator( - $this->schemaProcessor, - $this->schema, - $propertySchema, - $property->getName(), - ) - ); - - return; - } - - $expectedAmount = count($json[self::JSON_FIELD_ITEMS]); - - $property->addValidator( - new PropertyValidator( - $property, - '($amount = count($value)) > ' . $expectedAmount, - AdditionalTupleItemsException::class, - [$expectedAmount, '&$amount'], - ) - ); - } - - /** - * Add the validator to check for constraints required for at least one item - * - * @throws SchemaException - */ - private function addContainsValidation(PropertyInterface $property, JsonSchema $propertySchema): void - { - if (!isset($propertySchema->getJson()[self::JSON_FIELD_CONTAINS])) { - return; - } - - // an item of the array behaves like a nested property to add item-level validation - $nestedProperty = (new PropertyFactory(new PropertyProcessorFactory())) - ->create( - new PropertyMetaDataCollection(), - $this->schemaProcessor, - $this->schema, - "item of array {$property->getName()}", - $propertySchema->withJson($propertySchema->getJson()[self::JSON_FIELD_CONTAINS]), - ); - - $property->addValidator( - new PropertyTemplateValidator( - $property, - DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ArrayContains.phptpl', - [ - 'property' => $nestedProperty, - 'schema' => $this->schema, - 'viewHelper' => new RenderHelper($this->schemaProcessor->getGeneratorConfiguration()), - 'generatorConfiguration' => $this->schemaProcessor->getGeneratorConfiguration(), - ], - ContainsException::class, - ) - ); - } -} diff --git a/src/PropertyProcessor/Property/BaseProcessor.php b/src/PropertyProcessor/Property/BaseProcessor.php deleted file mode 100644 index 8368e69b..00000000 --- a/src/PropertyProcessor/Property/BaseProcessor.php +++ /dev/null @@ -1,481 +0,0 @@ -_rawModelDataInput), - array_keys($modelData), - ) - ), - )'; - - /** - * @inheritdoc - * - * @throws FileSystemException - * @throws SchemaException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - public function process(string $propertyName, JsonSchema $propertySchema): PropertyInterface - { - $this->schema - ->getSchemaDictionary() - ->setUpDefinitionDictionary($this->schemaProcessor, $this->schema); - - // create a property which is used to gather composed properties validators. - $property = new BaseProperty($propertyName, new PropertyType(static::TYPE), $propertySchema); - $this->generateValidators($property, $propertySchema); - - $this->addPropertiesToSchema($propertySchema); - $this->transferComposedPropertiesToSchema($property); - - $this->addPropertyNamesValidator($propertySchema); - $this->addPatternPropertiesValidator($propertySchema); - $this->addAdditionalPropertiesValidator($propertySchema); - - $this->addMinPropertiesValidator($propertyName, $propertySchema); - $this->addMaxPropertiesValidator($propertyName, $propertySchema); - - return $property; - } - - /** - * Add a validator to check all provided property names - * - * @throws SchemaException - * @throws FileSystemException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - protected function addPropertyNamesValidator(JsonSchema $propertySchema): void - { - if (!isset($propertySchema->getJson()['propertyNames'])) { - return; - } - - $this->schema->addBaseValidator( - new PropertyNamesValidator( - $this->schemaProcessor, - $this->schema, - $propertySchema->withJson($propertySchema->getJson()['propertyNames']), - ) - ); - } - - /** - * Add an object validator to specify constraints for properties which are not defined in the schema - * - * @throws FileSystemException - * @throws SchemaException - * @throws SyntaxErrorException - * @throws UndefinedSymbolException - */ - protected function addAdditionalPropertiesValidator(JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if ( - !isset($json['additionalProperties']) && - $this->schemaProcessor->getGeneratorConfiguration()->denyAdditionalProperties() - ) { - $json['additionalProperties'] = false; - } - - if (!isset($json['additionalProperties']) || $json['additionalProperties'] === true) { - return; - } - - if (!is_bool($json['additionalProperties'])) { - $this->schema->addBaseValidator( - new AdditionalPropertiesValidator( - $this->schemaProcessor, - $this->schema, - $propertySchema, - ) - ); - - return; - } - - $this->schema->addBaseValidator( - new NoAdditionalPropertiesValidator( - new Property($this->schema->getClassName(), null, $propertySchema), - $json, - ) - ); - } - - /** - * @throws SchemaException - */ - protected function addPatternPropertiesValidator(JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!isset($json['patternProperties'])) { - return; - } - - foreach ($json['patternProperties'] as $pattern => $schema) { - $escapedPattern = addcslashes((string) $pattern, '/'); - - if (@preg_match("/$escapedPattern/", '') === false) { - throw new SchemaException( - "Invalid pattern '$pattern' for pattern property in file {$propertySchema->getFile()}", - ); - } - - $validator = new PatternPropertiesValidator( - $this->schemaProcessor, - $this->schema, - $pattern, - $propertySchema->withJson($schema), - ); - - $this->schema->addBaseValidator($validator); - } - } - - /** - * Add an object validator to limit the amount of provided properties - * - * @throws SchemaException - */ - protected function addMaxPropertiesValidator(string $propertyName, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!isset($json['maxProperties'])) { - return; - } - - $this->schema->addBaseValidator( - new PropertyValidator( - new Property($propertyName, null, $propertySchema), - sprintf( - '%s > %d', - self::COUNT_PROPERTIES, - $json['maxProperties'], - ), - MaxPropertiesException::class, - [$json['maxProperties']], - ) - ); - } - - /** - * Add an object validator to force at least the defined amount of properties to be provided - * - * @throws SchemaException - */ - protected function addMinPropertiesValidator(string $propertyName, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!isset($json['minProperties'])) { - return; - } - - $this->schema->addBaseValidator( - new PropertyValidator( - new Property($propertyName, null, $propertySchema), - sprintf( - '%s < %d', - self::COUNT_PROPERTIES, - $json['minProperties'], - ), - MinPropertiesException::class, - [$json['minProperties']], - ) - ); - } - - /** - * Add the properties defined in the JSON schema to the current schema model - * - * @throws SchemaException - */ - protected function addPropertiesToSchema(JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - $propertyFactory = new PropertyFactory(new PropertyProcessorFactory()); - $propertyMetaDataCollection = new PropertyMetaDataCollection( - $json['required'] ?? [], - $json['dependencies'] ?? [], - ); - - $json['properties'] ??= []; - // setup empty properties for required properties which aren't defined in the properties section of the schema - $json['properties'] += array_fill_keys( - array_diff($json['required'] ?? [], array_keys($json['properties'])), - [], - ); - - foreach ($json['properties'] as $propertyName => $propertyStructure) { - if ($propertyStructure === false) { - if (in_array($propertyName, $json['required'] ?? [], true)) { - throw new SchemaException( - sprintf( - "Property '%s' is denied (schema false) but also listed as required in file %s", - $propertyName, - $propertySchema->getFile(), - ), - ); - } - - $this->schema->addBaseValidator( - new PropertyValidator( - new Property($propertyName, null, $propertySchema->withJson([])), - "array_key_exists('" . addslashes($propertyName) . "', \$modelData)", - DeniedPropertyException::class, - ) - ); - continue; - } - - $this->schema->addProperty( - $propertyFactory->create( - $propertyMetaDataCollection, - $this->schemaProcessor, - $this->schema, - (string) $propertyName, - $propertySchema->withJson($propertyStructure), - ) - ); - } - } - - /** - * Transfer properties of composed properties to the current schema to offer a complete model including all - * composed properties. - * - * @throws SchemaException - */ - protected function transferComposedPropertiesToSchema(PropertyInterface $property): void - { - foreach ($property->getValidators() as $validator) { - $validator = $validator->getValidator(); - - if (!is_a($validator, AbstractComposedPropertyValidator::class)) { - continue; - } - - // If the transferred validator of the composed property is also a composed property strip the nested - // composition validations from the added validator. The nested composition will be validated in the object - // generated for the nested composition which will be executed via an instantiation. Consequently, the - // validation must not be executed in the outer composition. - $this->schema->addBaseValidator( - ($validator instanceof ComposedPropertyValidator) - ? $validator->withoutNestedCompositionValidation() - : $validator, - ); - - if (!is_a($validator->getCompositionProcessor(), ComposedPropertiesInterface::class, true)) { - continue; - } - - $branchesForValidator = $validator instanceof ConditionalPropertyValidator - ? $validator->getConditionBranches() - : $validator->getComposedProperties(); - - $totalBranches = count($branchesForValidator); - $resolvedPropertiesCallbacks = 0; - $seenBranchPropertyNames = []; - - foreach ($validator->getComposedProperties() as $composedProperty) { - $composedProperty->onResolve(function () use ( - $composedProperty, - $property, - $validator, - $branchesForValidator, - $totalBranches, - &$resolvedPropertiesCallbacks, - &$seenBranchPropertyNames, - ): void { - if (!$composedProperty->getNestedSchema()) { - throw new SchemaException( - sprintf( - "No nested schema for composed property %s in file %s found", - $property->getName(), - $property->getJsonSchema()->getFile(), - ) - ); - } - - $isBranchForValidator = in_array($composedProperty, $branchesForValidator, true); - - $composedProperty->getNestedSchema()->onAllPropertiesResolved( - function () use ( - $composedProperty, - $validator, - $isBranchForValidator, - $totalBranches, - &$resolvedPropertiesCallbacks, - &$seenBranchPropertyNames, - ): void { - foreach ($composedProperty->getNestedSchema()->getProperties() as $branchProperty) { - $this->schema->addProperty( - $this->cloneTransferredProperty( - $branchProperty, - $composedProperty, - $validator, - ), - $validator->getCompositionProcessor(), - ); - - $composedProperty->appendAffectedObjectProperty($branchProperty); - $seenBranchPropertyNames[$branchProperty->getName()] = true; - } - - if ($isBranchForValidator && ++$resolvedPropertiesCallbacks === $totalBranches) { - foreach (array_keys($seenBranchPropertyNames) as $branchPropertyName) { - $this->schema->getPropertyMerger()->checkForTotalConflict( - $branchPropertyName, - $totalBranches, - ); - } - } - }, - ); - }); - } - } - } - - /** - * Clone the provided property to transfer it to a schema. Sets the nullability and required flag based on the - * composition processor used to set up the composition. Widens the type to mixed when the property is exclusive - * to one anyOf/oneOf branch and at least one other branch allows additional properties, preventing TypeError when - * raw input values of an arbitrary type are stored in the property slot. - */ - private function cloneTransferredProperty( - PropertyInterface $property, - CompositionPropertyDecorator $sourceBranch, - AbstractComposedPropertyValidator $validator, - ): PropertyInterface { - $compositionProcessor = $validator->getCompositionProcessor(); - - $transferredProperty = (clone $property) - ->filterValidators(static fn(Validator $validator): bool => - is_a($validator->getValidator(), PropertyTemplateValidator::class)); - - if (!is_a($compositionProcessor, AllOfProcessor::class, true)) { - $transferredProperty->setRequired(false); - - if ($transferredProperty->getType()) { - $transferredProperty->setType( - new PropertyType($transferredProperty->getType()->getNames(), true), - new PropertyType($transferredProperty->getType(true)->getNames(), true), - ); - } - - $wideningBranches = $validator instanceof ConditionalPropertyValidator - ? $validator->getConditionBranches() - : $validator->getComposedProperties(); - - if ($this->exclusiveBranchPropertyNeedsWidening($property->getName(), $sourceBranch, $wideningBranches)) { - $transferredProperty->setType(null, null, reset: true); - } - } - - return $transferredProperty; - } - - /** - * Returns true when the property named $propertyName is exclusive to $sourceBranch and at least - * one other anyOf/oneOf branch allows additional properties (i.e. does NOT declare - * additionalProperties: false). In that case the property slot can receive an arbitrarily-typed - * raw input value from a non-matching branch, so the native type hint must be removed. - * - * Returns false when the property appears in another branch too (Schema::addProperty handles - * that via type merging) or when all other branches have additionalProperties: false (making - * the property mutually exclusive with the other branches' properties). - * - * @param CompositionPropertyDecorator[] $allBranches - */ - private function exclusiveBranchPropertyNeedsWidening( - string $propertyName, - CompositionPropertyDecorator $sourceBranch, - array $allBranches, - ): bool { - // Pass 1: if any other branch defines the same property, Phase 6 handles the type - // merging via Schema::addProperty — widening to mixed is not needed here. - foreach ($allBranches as $branch) { - if ($branch === $sourceBranch) { - continue; - } - - $branchPropertyNames = $branch->getNestedSchema() - ? array_map( - static fn(PropertyInterface $p): string => $p->getName(), - $branch->getNestedSchema()->getProperties(), - ) - : []; - - if (in_array($propertyName, $branchPropertyNames, true)) { - return false; - } - } - - // Pass 2: the property is exclusive to $sourceBranch. Widening is needed when at - // least one other branch allows additional properties — an arbitrary input value can - // then land in this slot when that branch is the one that matched. - foreach ($allBranches as $branch) { - if ($branch === $sourceBranch) { - continue; - } - - if (($branch->getBranchSchema()->getJson()['additionalProperties'] ?? true) !== false) { - return true; - } - } - - // All other branches have additionalProperties:false — no arbitrary value can arrive. - return false; - } -} diff --git a/src/PropertyProcessor/Property/BasereferenceProcessor.php b/src/PropertyProcessor/Property/BasereferenceProcessor.php deleted file mode 100644 index 518a6605..00000000 --- a/src/PropertyProcessor/Property/BasereferenceProcessor.php +++ /dev/null @@ -1,48 +0,0 @@ -schema - ->getSchemaDictionary() - ->setUpDefinitionDictionary($this->schemaProcessor, $this->schema); - - $property = parent::process($propertyName, $propertySchema); - - if (!$property->getNestedSchema()) { - throw new SchemaException( - sprintf( - 'A referenced schema on base level must provide an object definition for property %s in file %s', - $propertyName, - $propertySchema->getFile(), - ) - ); - } - - foreach ($property->getNestedSchema()->getProperties() as $propertiesOfReferencedObject) { - $this->schema->addProperty($propertiesOfReferencedObject); - } - - return $property; - } -} diff --git a/src/PropertyProcessor/Property/BooleanProcessor.php b/src/PropertyProcessor/Property/BooleanProcessor.php deleted file mode 100644 index 2d6bc48d..00000000 --- a/src/PropertyProcessor/Property/BooleanProcessor.php +++ /dev/null @@ -1,15 +0,0 @@ -propertyProcessors[$type] = $propertyProcessorFactory->getProcessor( - $type, - $propertyMetaDataCollection, - $schemaProcessor, - $schema, - ); - } - } - - /** - * Process a property - * - * @param string $propertyName The name of the property - * @param JsonSchema $propertySchema The schema of the property - * - * @throws SchemaException - * @throws ReflectionException - */ - public function process(string $propertyName, JsonSchema $propertySchema): PropertyInterface - { - $property = parent::process($propertyName, $propertySchema); - - $property->onResolve(function () use ($property, $propertyName, $propertySchema): void { - foreach ($property->getValidators() as $validator) { - $this->checks[] = $validator->getValidator()->getCheck(); - } - - $subProperties = $this->processSubProperties($propertyName, $propertySchema, $property); - - $processedSubProperties = 0; - foreach ($subProperties as $subProperty) { - $subProperty->onResolve(function () use ($property, $subProperties, &$processedSubProperties): void { - if (++$processedSubProperties === count($subProperties)) { - if (empty($this->allowedPropertyTypes)) { - return; - } - - $property->addTypeHintDecorator( - new TypeHintDecorator( - array_map( - static fn(PropertyInterface $subProperty): string => $subProperty->getTypeHint(), - $subProperties, - ) - ), - ); - - $property->addValidator( - new MultiTypeCheckValidator( - array_unique($this->allowedPropertyTypes), - $property, - $this->isImplicitNullAllowed($property), - ), - 2, - ); - - // Set a union PropertyType so the native PHP type hint path can emit - // e.g. float|string|array instead of falling back to no hint at all. - // 'null' must be converted to nullable=true rather than kept as a type name, - // otherwise the render pipeline would emit string|null|null. - $hasNull = in_array('null', $this->allowedPropertyTypes, true); - $nonNullTypes = array_values(array_filter( - $this->allowedPropertyTypes, - fn(string $type): bool => $type !== 'null', - )); - - if ($nonNullTypes) { - $property->setType( - new PropertyType($nonNullTypes, $hasNull ? true : null), - new PropertyType($nonNullTypes, $hasNull ? true : null), - ); - } - } - }); - } - }); - - return $property; - } - - /** - * Move validators from the $source property to the $destination property - */ - protected function transferValidators(PropertyInterface $source, PropertyInterface $destination) - { - foreach ($source->getValidators() as $validatorContainer) { - $validator = $validatorContainer->getValidator(); - - // filter out type checks to create a single type check which covers all allowed types - if ($validator instanceof TypeCheckInterface) { - array_push($this->allowedPropertyTypes, ...$validator->getTypes()); - - continue; - } - - // remove duplicated checks like an isset check - if (in_array($validator->getCheck(), $this->checks)) { - continue; - } - - $destination->addValidator($validator, $validatorContainer->getPriority()); - $this->checks[] = $validator->getCheck(); - } - } - - /** - * @return PropertyInterface[] - * - * @throws SchemaException - */ - protected function processSubProperties( - string $propertyName, - JsonSchema $propertySchema, - PropertyInterface $property, - ): array { - $defaultValue = null; - $invalidDefaultValueException = null; - $invalidDefaultValues = 0; - $subProperties = []; - $json = $propertySchema->getJson(); - - if (isset($json['default'])) { - $defaultValue = $json['default']; - unset($json['default']); - } - - foreach ($this->propertyProcessors as $type => $propertyProcessor) { - $json['type'] = $type; - - $subProperty = $propertyProcessor->process($propertyName, $propertySchema->withJson($json)); - - $subProperty->onResolve(function () use ($property, $subProperty): void { - $this->transferValidators($subProperty, $property); - - if ($subProperty->getDecorators()) { - $property->addDecorator(new PropertyTransferDecorator($subProperty)); - } - }); - - if ($defaultValue !== null && $propertyProcessor instanceof AbstractTypedValueProcessor) { - try { - $propertyProcessor->setDefaultValue($property, $defaultValue, $propertySchema); - } catch (SchemaException $e) { - $invalidDefaultValues++; - $invalidDefaultValueException = $e; - } - } - - $subProperties[] = $subProperty; - } - - if ($invalidDefaultValues === count($this->propertyProcessors)) { - throw $invalidDefaultValueException; - } - - return $subProperties; - } -} diff --git a/src/PropertyProcessor/Property/NullProcessor.php b/src/PropertyProcessor/Property/NullProcessor.php deleted file mode 100644 index 3d295b41..00000000 --- a/src/PropertyProcessor/Property/NullProcessor.php +++ /dev/null @@ -1,29 +0,0 @@ -setType(null) - ->addTypeHintDecorator(new TypeHintDecorator(['null'])); - } -} diff --git a/src/PropertyProcessor/Property/NumberProcessor.php b/src/PropertyProcessor/Property/NumberProcessor.php deleted file mode 100644 index d401e1fd..00000000 --- a/src/PropertyProcessor/Property/NumberProcessor.php +++ /dev/null @@ -1,27 +0,0 @@ -addDecorator(new IntToFloatCastDecorator()); - } -} diff --git a/src/PropertyProcessor/Property/ObjectProcessor.php b/src/PropertyProcessor/Property/ObjectProcessor.php deleted file mode 100644 index fb40f886..00000000 --- a/src/PropertyProcessor/Property/ObjectProcessor.php +++ /dev/null @@ -1,81 +0,0 @@ -schemaProcessor->getGeneratorConfiguration()->getClassNameGenerator()->getClassName( - $propertyName, - $propertySchema, - false, - $this->schemaProcessor->getCurrentClassName(), - ); - - $schema = $this->schemaProcessor->processSchema( - $propertySchema, - $this->schemaProcessor->getCurrentClassPath(), - $className, - $this->schema->getSchemaDictionary(), - ); - - // if the generated schema is located in a different namespace (the schema for the given structure in - // $propertySchema is duplicated) add used classes to the current schema. By importing the class which is - // represented by $schema and by transferring all imports of $schema as well as imports for all properties - // of $schema to $this->schema the already generated schema can be used - if ( - $schema->getClassPath() !== $this->schema->getClassPath() || - $schema->getClassName() !== $this->schema->getClassName() - ) { - $this->schema->addUsedClass( - join( - '\\', - array_filter([ - $this->schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(), - $schema->getClassPath(), - $schema->getClassName(), - ]), - ) - ); - - $this->schema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($schema)); - } - - $property - ->addDecorator( - new ObjectInstantiationDecorator( - $schema->getClassName(), - $this->schemaProcessor->getGeneratorConfiguration(), - ) - ) - ->setType(new PropertyType($schema->getClassName())) - ->setNestedSchema($schema); - - return $property->addValidator(new InstanceOfValidator($property), 3); - } -} diff --git a/src/PropertyProcessor/Property/ReferenceProcessor.php b/src/PropertyProcessor/Property/ReferenceProcessor.php deleted file mode 100644 index bcc73029..00000000 --- a/src/PropertyProcessor/Property/ReferenceProcessor.php +++ /dev/null @@ -1,76 +0,0 @@ -getJson()['$ref']; - $dictionary = $this->schema->getSchemaDictionary(); - - try { - $definition = $dictionary->getDefinition($reference, $this->schemaProcessor, $path); - - if ($definition) { - $definitionSchema = $definition->getSchema(); - - if ( - $this->schema->getClassPath() !== $definitionSchema->getClassPath() || - $this->schema->getClassName() !== $definitionSchema->getClassName() || - ( - $this->schema->getClassName() === 'ExternalSchema' && - $definitionSchema->getClassName() === 'ExternalSchema' - ) - ) { - $this->schema->addNamespaceTransferDecorator( - new SchemaNamespaceTransferDecorator($definitionSchema), - ); - - // When the definition resolves to a canonical (non-ExternalSchema) class that - // lives in a different namespace from the current schema, register its FQCN - // directly as a used class. The ExternalSchema intermediary that previously - // performed this registration (transitively via its own usedClasses list) is - // no longer created when the file was already processed; this explicit call - // ensures the referencing schema's import list remains complete. - if ($definitionSchema->getClassName() !== 'ExternalSchema') { - $this->schema->addUsedClass(join('\\', array_filter([ - $this->schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(), - $definitionSchema->getClassPath(), - $definitionSchema->getClassName(), - ]))); - } - } - - return $definition->resolveReference($propertyName, $path, $this->propertyMetaDataCollection); - } - } catch (Exception $exception) { - throw new SchemaException( - "Unresolved Reference $reference in file {$propertySchema->getFile()}", - 0, - $exception, - ); - } - - throw new SchemaException("Unresolved Reference $reference in file {$propertySchema->getFile()}"); - } -} diff --git a/src/PropertyProcessor/Property/StringProcessor.php b/src/PropertyProcessor/Property/StringProcessor.php deleted file mode 100644 index 8b3bb7b9..00000000 --- a/src/PropertyProcessor/Property/StringProcessor.php +++ /dev/null @@ -1,142 +0,0 @@ -addPatternValidator($property, $propertySchema); - $this->addLengthValidator($property, $propertySchema); - $this->addFormatValidator($property, $propertySchema); - } - - /** - * Add a regex pattern validator - * - * @throws SchemaException - */ - protected function addPatternValidator(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (!isset($json[static::JSON_FIELD_PATTERN])) { - return; - } - - $escapedPattern = addcslashes((string) $json[static::JSON_FIELD_PATTERN], '/'); - - if (@preg_match("/$escapedPattern/", '') === false) { - throw new SchemaException( - sprintf( - "Invalid pattern '%s' for property '%s' in file %s", - $json[static::JSON_FIELD_PATTERN], - $property->getName(), - $propertySchema->getFile(), - ) - ); - } - - $encodedPattern = base64_encode("/$escapedPattern/"); - - $property->addValidator( - new PropertyValidator( - $property, - $this->getTypeCheck() . "!preg_match(base64_decode('$encodedPattern'), \$value)", - PatternException::class, - [$json[static::JSON_FIELD_PATTERN]], - ) - ); - } - - /** - * Add min and max length validator - */ - protected function addLengthValidator(PropertyInterface $property, JsonSchema $propertySchema): void - { - $json = $propertySchema->getJson(); - - if (isset($json[static::JSON_FIELD_MIN_LENGTH])) { - $property->addValidator( - new PropertyValidator( - $property, - $this->getTypeCheck() . "mb_strlen(\$value) < {$json[static::JSON_FIELD_MIN_LENGTH]}", - MinLengthException::class, - [$json[static::JSON_FIELD_MIN_LENGTH]], - ) - ); - } - - if (isset($json[static::JSON_FIELD_MAX_LENGTH])) { - $property->addValidator( - new PropertyValidator( - $property, - $this->getTypeCheck() . "mb_strlen(\$value) > {$json[static::JSON_FIELD_MAX_LENGTH]}", - MaxLengthException::class, - [$json[static::JSON_FIELD_MAX_LENGTH]], - ) - ); - } - } - - /** - * @throws SchemaException - */ - protected function addFormatValidator(PropertyInterface $property, JsonSchema $propertySchema): void - { - if (!isset($propertySchema->getJson()[self::JSON_FIELD_FORMAT])) { - return; - } - - $formatValidator = $this->schemaProcessor - ->getGeneratorConfiguration() - ->getFormat($propertySchema->getJson()[self::JSON_FIELD_FORMAT]); - - if (!$formatValidator) { - throw new SchemaException( - sprintf( - 'Unsupported format %s for property %s in file %s', - $propertySchema->getJson()[self::JSON_FIELD_FORMAT], - $property->getName(), - $propertySchema->getFile(), - ) - ); - } - - $property->addValidator( - new FormatValidator( - $property, - $formatValidator, - [$propertySchema->getJson()[self::JSON_FIELD_FORMAT]], - ) - ); - } -} diff --git a/src/PropertyProcessor/PropertyFactory.php b/src/PropertyProcessor/PropertyFactory.php index 1407e68a..e4c03b7b 100644 --- a/src/PropertyProcessor/PropertyFactory.php +++ b/src/PropertyProcessor/PropertyFactory.php @@ -4,11 +4,34 @@ namespace PHPModelGenerator\PropertyProcessor; +use PHPModelGenerator\Attributes\Deprecated; +use PHPModelGenerator\Attributes\JsonPointer; +use PHPModelGenerator\Attributes\JsonSchema as JsonSchemaAttribute; +use PHPModelGenerator\Attributes\ReadOnlyProperty; +use PHPModelGenerator\Attributes\Required; +use PHPModelGenerator\Attributes\SchemaName; +use PHPModelGenerator\Attributes\WriteOnlyProperty; +use Exception; +use PHPModelGenerator\Draft\Draft; +use PHPModelGenerator\Draft\DraftFactoryInterface; +use PHPModelGenerator\Draft\Modifier\ObjectType\ObjectModifier; +use PHPModelGenerator\Draft\Modifier\TypeCheckModifier; use PHPModelGenerator\Exception\SchemaException; +use PHPModelGenerator\Model\Attributes\PhpAttribute; +use PHPModelGenerator\Model\Property\BaseProperty; +use PHPModelGenerator\Model\Property\Property; use PHPModelGenerator\Model\Property\PropertyInterface; +use PHPModelGenerator\Model\Property\PropertyType; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; +use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator; +use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; +use PHPModelGenerator\Model\Validator\TypeCheckInterface; +use PHPModelGenerator\PropertyProcessor\Decorator\Property\PropertyTransferDecorator; +use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; +use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\TypeConverter; /** * Class PropertyFactory @@ -17,41 +40,595 @@ */ class PropertyFactory { - public function __construct(protected ProcessorFactoryInterface $processorFactory) - {} + /** @var Draft[] Keyed by draft class name */ + private array $draftCache = []; /** - * Create a property + * Create a property, applying all applicable Draft modifiers. * * @throws SchemaException */ public function create( - PropertyMetaDataCollection $propertyMetaDataCollection, SchemaProcessor $schemaProcessor, Schema $schema, string $propertyName, JsonSchema $propertySchema, + bool $required = false, ): PropertyInterface { $json = $propertySchema->getJson(); - // redirect properties with a constant value to the ConstProcessor - if (isset($json['const'])) { - $json['type'] = 'const'; - } - // redirect references to the ReferenceProcessor + // $ref: replace the property entirely via the definition dictionary. + // This is a schema-identity primitive — it cannot be a Draft modifier because + // ModifierInterface::modify returns void and cannot replace the property object. if (isset($json['$ref'])) { - $json['type'] = isset($json['type']) && $json['type'] === 'base' - ? 'baseReference' - : 'reference'; + if (isset($json['type']) && $json['type'] === 'base') { + return $this->processBaseReference( + $schemaProcessor, + $schema, + $propertyName, + $propertySchema, + $required, + ); + } + + return $this->processReference($schemaProcessor, $schema, $propertyName, $propertySchema, $required); } - return $this->processorFactory - ->getProcessor( - $json['type'] ?? 'any', - $propertyMetaDataCollection, + $resolvedType = $json['type'] ?? 'any'; + + if (is_array($resolvedType)) { + return $this->createMultiTypeProperty( $schemaProcessor, $schema, + $propertyName, + $propertySchema, + $resolvedType, + $required, + ); + } + + $this->checkType($resolvedType, $schema); + + return match ($resolvedType) { + 'object' => $this->createObjectProperty( + $schemaProcessor, + $schema, + $propertyName, + $propertySchema, + $required, + ), + 'base' => $this->createBaseProperty($schemaProcessor, $schema, $propertyName, $propertySchema), + default => $this->createTypedProperty( + $schemaProcessor, + $schema, + $propertyName, + $propertySchema, + $resolvedType, + $required, + ), + }; + } + + /** + * Handle a nested object property: generate the nested class, wire the outer property, + * then apply universal modifiers (filter, enum, default, const) on the outer property. + * + * @throws SchemaException + */ + private function createObjectProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + bool $required, + ): PropertyInterface { + $json = $propertySchema->getJson(); + $property = $this->buildProperty($schemaProcessor, $propertyName, null, $propertySchema, $required); + + $className = $schemaProcessor->getGeneratorConfiguration()->getClassNameGenerator()->getClassName( + $propertyName, + $propertySchema, + false, + $schemaProcessor->getCurrentClassName(), + ); + + // Strip property-level keywords before passing the schema to processSchema: these keywords + // target the outer property and are handled by the universal modifiers below. + $nestedJson = $json; + unset($nestedJson['filter'], $nestedJson['enum'], $nestedJson['default']); + + $nestedSchema = $schemaProcessor->processSchema( + $propertySchema->withJson($nestedJson), + $schemaProcessor->getCurrentClassPath(), + $className, + $schema->getSchemaDictionary(), + ); + + if ($nestedSchema !== null) { + $property->setNestedSchema($nestedSchema); + $this->wireObjectProperty($schemaProcessor, $schema, $property, $propertySchema); + } + + // Universal modifiers (filter, enum, default, const) run on the outer property. + $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema, anyOnly: true); + + return $property; + } + + /** + * Handle a root-level schema (type=base): set up definitions, run all Draft modifiers, + * then transfer any composed properties to the schema. + * + * @throws SchemaException + */ + private function createBaseProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + ): PropertyInterface { + $schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema); + $property = new BaseProperty($propertyName, new PropertyType('object'), $propertySchema); + + $objectJson = $propertySchema->getJson(); + $objectJson['type'] = 'object'; + $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema->withJson($objectJson)); + + $schemaProcessor->transferComposedPropertiesToSchema($property, $schema); + + return $property; + } + + /** + * Handle scalar, array, and untyped properties: construct directly and run all Draft modifiers. + * + * @throws SchemaException + */ + private function createTypedProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + string $type, + bool $required, + ): PropertyInterface { + $phpType = $type !== 'any' ? TypeConverter::jsonSchemaToPHP($type) : null; + $property = $this->buildProperty( + $schemaProcessor, + $propertyName, + $phpType !== null ? new PropertyType($phpType) : null, + $propertySchema, + $required, + ); + + $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema); + + return $property; + } + + /** + * Construct a Property with the common required/readOnly setup. + */ + private function buildProperty( + SchemaProcessor $schemaProcessor, + string $propertyName, + ?PropertyType $type, + JsonSchema $propertySchema, + bool $required, + ): Property { + $json = $propertySchema->getJson(); + + $property = (new Property($propertyName, $type, $propertySchema, $json['description'] ?? '')) + ->setRequired($required) + ->setReadOnly( + (isset($json['readOnly']) && $json['readOnly'] === true) || + $schemaProcessor->getGeneratorConfiguration()->isImmutable(), + ); + + if ($required && !str_starts_with($propertyName, 'item of array ')) { + $property->addValidator(new RequiredPropertyValidator($property), 1); + } + + $configuration = $schemaProcessor->getGeneratorConfiguration(); + + $property + ->addAttribute( + new PhpAttribute(JsonPointer::class, [$propertySchema->getPointer()]), + $configuration, + PhpAttribute::JSON_POINTER, + ) + ->addAttribute( + new PhpAttribute(SchemaName::class, [$propertyName]), + $configuration, + PhpAttribute::SCHEMA_NAME, ) - ->process($propertyName, $propertySchema); + ->addAttribute( + new PhpAttribute( + JsonSchemaAttribute::class, + [empty($propertySchema->getJson()) ? '{}' : json_encode($propertySchema->getJson())], + ), + $configuration, + PhpAttribute::JSON_SCHEMA, + ); + + if ($required) { + $property->addAttribute(new PhpAttribute(Required::class), $configuration, PhpAttribute::REQUIRED); + } + + if (isset($json['readOnly']) && $json['readOnly'] === true) { + $property->addAttribute( + new PhpAttribute(ReadOnlyProperty::class), + $configuration, + PhpAttribute::READ_WRITE_ONLY, + ); + } + + if (isset($json['writeOnly']) && $json['writeOnly'] === true) { + $property->addAttribute( + new PhpAttribute(WriteOnlyProperty::class), + $configuration, + PhpAttribute::READ_WRITE_ONLY, + ); + } + + if (isset($json['deprecated']) && $json['deprecated'] === true) { + $property->addAttribute(new PhpAttribute(Deprecated::class), $configuration, PhpAttribute::DEPRECATED); + } + + return $property; + } + + /** + * Resolve a $ref reference by looking it up in the definition dictionary. + * + * @throws SchemaException + */ + private function processReference( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + bool $required, + ): PropertyInterface { + $path = []; + $reference = $propertySchema->getJson()['$ref']; + $dictionary = $schema->getSchemaDictionary(); + + try { + $definition = $dictionary->getDefinition($reference, $schemaProcessor, $path); + + if ($definition) { + $definitionSchema = $definition->getSchema(); + + if ( + $schema->getClassPath() !== $definitionSchema->getClassPath() || + $schema->getClassName() !== $definitionSchema->getClassName() || + ( + $schema->getClassName() === 'ExternalSchema' && + $definitionSchema->getClassName() === 'ExternalSchema' + ) + ) { + $schema->addNamespaceTransferDecorator( + new SchemaNamespaceTransferDecorator($definitionSchema), + ); + + if ($definitionSchema->getClassName() !== 'ExternalSchema') { + $schema->addUsedClass(join('\\', array_filter([ + $schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(), + $definitionSchema->getClassPath(), + $definitionSchema->getClassName(), + ]))); + } + } + + return $definition->resolveReference( + $propertyName, + implode('/', $path), + $required, + $propertySchema->getJson()['_dependencies'] ?? null, + ); + } + } catch (Exception $exception) { + throw new SchemaException( + "Unresolved Reference $reference in file {$propertySchema->getFile()}", + 0, + $exception, + ); + } + + throw new SchemaException("Unresolved Reference $reference in file {$propertySchema->getFile()}"); + } + + /** + * Resolve a $ref on a base-level schema: set up definitions, delegate to processReference, + * then copy the referenced schema's properties to the parent schema. + * + * @throws SchemaException + */ + private function processBaseReference( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + bool $required, + ): PropertyInterface { + $schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema); + + $property = $this->processReference($schemaProcessor, $schema, $propertyName, $propertySchema, $required); + + if (!$property->getNestedSchema()) { + throw new SchemaException( + sprintf( + 'A referenced schema on base level must provide an object definition for property %s in file %s', + $propertyName, + $propertySchema->getFile(), + ) + ); + } + + foreach ($property->getNestedSchema()->getProperties() as $propertiesOfReferencedObject) { + $schema->addProperty($propertiesOfReferencedObject); + } + + return $property; + } + + /** + * Handle "type": [...] properties by processing each type through its Draft modifiers, + * merging validators and decorators onto a single property, then consolidating type checks. + * + * @param string[] $types + * + * @throws SchemaException + */ + private function createMultiTypeProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + array $types, + bool $required, + ): PropertyInterface { + $json = $propertySchema->getJson(); + $property = $this->buildProperty($schemaProcessor, $propertyName, null, $propertySchema, $required); + + $collectedTypes = []; + $typeHints = []; + $resolvedSubCount = 0; + $totalSubCount = count($types); + + // Strip the default from sub-schemas so that default handling runs only once via the + // universal DefaultValueModifier below, which already handles the multi-type case. + $subJson = $json; + unset($subJson['default']); + + foreach ($types as $type) { + $this->checkType($type, $schema); + + $subJson['type'] = $type; + $subSchema = $propertySchema->withJson($subJson); + + // For type=object, delegate to the same object path (processSchema + wireObjectProperty). + $subProperty = $type === 'object' + ? $this->createObjectProperty($schemaProcessor, $schema, $propertyName, $subSchema, $required) + : $this->createSubTypeProperty( + $schemaProcessor, + $schema, + $propertyName, + $subSchema, + $type, + $required, + ); + + $subProperty->onResolve(function () use ( + $property, + $subProperty, + $schemaProcessor, + $schema, + $propertySchema, + $totalSubCount, + &$collectedTypes, + &$typeHints, + &$resolvedSubCount, + ): void { + foreach ($subProperty->getValidators() as $validatorContainer) { + $validator = $validatorContainer->getValidator(); + + if ($validator instanceof TypeCheckInterface) { + array_push($collectedTypes, ...$validator->getTypes()); + continue; + } + + $property->addValidator($validator, $validatorContainer->getPriority()); + } + + if ($subProperty->getDecorators()) { + $property->addDecorator(new PropertyTransferDecorator($subProperty)); + } + + $typeHints[] = $subProperty->getTypeHint(); + + if (++$resolvedSubCount < $totalSubCount || empty($collectedTypes)) { + return; + } + + $this->finalizeMultiTypeProperty( + $property, + array_unique($collectedTypes), + $typeHints, + $schemaProcessor, + $schema, + $propertySchema, + ); + }); + } + + return $property; + } + + /** + * Build a non-object sub-property for a multi-type array, applying only type-specific + * modifiers (no universal 'any' modifiers — those run once on the parent after finalization). + * + * @throws SchemaException + */ + private function createSubTypeProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + string $type, + bool $required, + ): Property { + $subProperty = $this->buildProperty( + $schemaProcessor, + $propertyName, + new PropertyType(TypeConverter::jsonSchemaToPHP($type)), + $propertySchema, + $required, + ); + + $this->applyModifiers($schemaProcessor, $schema, $subProperty, $propertySchema, anyOnly: false, typeOnly: true); + + return $subProperty; + } + + /** + * Called once all sub-properties of a multi-type property have resolved. + * Adds the consolidated MultiTypeCheckValidator, sets the union PropertyType, + * attaches the type-hint decorator, and runs universal modifiers. + * + * @param string[] $collectedTypes + * @param string[] $typeHints + * + * @throws SchemaException + */ + private function finalizeMultiTypeProperty( + PropertyInterface $property, + array $collectedTypes, + array $typeHints, + SchemaProcessor $schemaProcessor, + Schema $schema, + JsonSchema $propertySchema, + ): void { + $hasNull = in_array('null', $collectedTypes, true); + $nonNullTypes = array_values(array_filter( + $collectedTypes, + static fn(string $type): bool => $type !== 'null', + )); + + $allowImplicitNull = $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed() + && !$property->isRequired(); + + $property->addValidator( + new MultiTypeCheckValidator($collectedTypes, $property, $allowImplicitNull), + 2, + ); + + if ($nonNullTypes) { + $property->setType( + new PropertyType($nonNullTypes, $hasNull ? true : null), + new PropertyType($nonNullTypes, $hasNull ? true : null), + ); + } + + $property->addTypeHintDecorator(new TypeHintDecorator($typeHints)); + + $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema, true); + } + + /** + * Wire the outer property for a nested object: add the type-check validator and instantiation + * linkage. Schema-targeting modifiers are intentionally NOT run here because processSchema + * already applied them to the nested schema. + * + * @throws SchemaException + */ + private function wireObjectProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + (new TypeCheckModifier(TypeConverter::jsonSchemaToPHP('object')))->modify( + $schemaProcessor, + $schema, + $property, + $propertySchema, + ); + + (new ObjectModifier())->modify($schemaProcessor, $schema, $property, $propertySchema); + } + + /** + * Run Draft modifiers for the given property. + * + * By default all covered types (type-specific + 'any') run. Pass $anyOnly=true to run + * only the 'any' entry (used for object outer-property universal keywords), or $typeOnly=true + * to run only type-specific entries (used for multi-type sub-properties). + * + * @throws SchemaException + */ + private function applyModifiers( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + bool $anyOnly = false, + bool $typeOnly = false, + ): void { + $type = $propertySchema->getJson()['type'] ?? 'any'; + $builtDraft = $this->resolveBuiltDraft($schemaProcessor, $propertySchema); + + // For untyped properties ('any'), only run the 'any' entry — getCoveredTypes('any') + // returns all types, which would incorrectly apply type-specific modifiers. + $coveredTypes = $type === 'any' + ? array_filter($builtDraft->getCoveredTypes('any'), static fn($t) => $t->getType() === 'any') + : $builtDraft->getCoveredTypes($type); + + foreach ($coveredTypes as $coveredType) { + $isAnyEntry = $coveredType->getType() === 'any'; + + if ($anyOnly && !$isAnyEntry) { + continue; + } + + if ($typeOnly && $isAnyEntry) { + continue; + } + + foreach ($coveredType->getModifiers() as $modifier) { + $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); + } + } + } + + /** + * @throws SchemaException + */ + private function checkType(mixed $type, Schema $schema): void + { + if (is_string($type)) { + return; + } + + throw new SchemaException( + sprintf( + 'Invalid property type %s in file %s', + $type, + $schema->getJsonSchema()->getFile(), + ) + ); + } + + private function resolveBuiltDraft(SchemaProcessor $schemaProcessor, JsonSchema $propertySchema): Draft + { + $configDraft = $schemaProcessor->getGeneratorConfiguration()->getDraft(); + + $draft = $configDraft instanceof DraftFactoryInterface + ? $configDraft->getDraftForSchema($propertySchema) + : $configDraft; + + return $this->draftCache[$draft::class] ??= $draft->getDefinition()->build(); } } diff --git a/src/PropertyProcessor/PropertyMetaDataCollection.php b/src/PropertyProcessor/PropertyMetaDataCollection.php deleted file mode 100644 index 727bb795..00000000 --- a/src/PropertyProcessor/PropertyMetaDataCollection.php +++ /dev/null @@ -1,51 +0,0 @@ -requiredAttributes); - } - - /** - * Get the dependencies for the requested attribute - */ - public function getAttributeDependencies(string $attribute): ?array - { - return $this->dependencies[$attribute] ?? null; - } - - public function getHash(string $attribute): string - { - return md5(json_encode([ - $this->getAttributeDependencies($attribute), - $this->isAttributeRequired($attribute), - ])); - } -} diff --git a/src/PropertyProcessor/PropertyProcessorFactory.php b/src/PropertyProcessor/PropertyProcessorFactory.php deleted file mode 100644 index 9b7f5f32..00000000 --- a/src/PropertyProcessor/PropertyProcessorFactory.php +++ /dev/null @@ -1,74 +0,0 @@ -getSingleTypePropertyProcessor( - $type, - $propertyMetaDataCollection, - $schemaProcessor, - $schema, - ); - } - - if (is_array($type)) { - return new MultiTypeProcessor($this, $type, $propertyMetaDataCollection, $schemaProcessor, $schema); - } - - throw new SchemaException( - sprintf( - 'Invalid property type %s in file %s', - $type, - $schema->getJsonSchema()->getFile(), - ) - ); - } - - /** - * @throws SchemaException - */ - protected function getSingleTypePropertyProcessor( - string $type, - PropertyMetaDataCollection $propertyMetaDataCollection, - SchemaProcessor $schemaProcessor, - Schema $schema, - ): PropertyProcessorInterface { - $processor = '\\PHPModelGenerator\\PropertyProcessor\\Property\\' . ucfirst(strtolower($type)) . 'Processor'; - if (!class_exists($processor)) { - throw new SchemaException( - sprintf( - 'Unsupported property type %s in file %s', - $type, - $schema->getJsonSchema()->getFile(), - ) - ); - } - - return new $processor($propertyMetaDataCollection, $schemaProcessor, $schema); - } -} diff --git a/src/PropertyProcessor/PropertyProcessorInterface.php b/src/PropertyProcessor/PropertyProcessorInterface.php deleted file mode 100644 index fb176ed7..00000000 --- a/src/PropertyProcessor/PropertyProcessorInterface.php +++ /dev/null @@ -1,24 +0,0 @@ -getClassName(), null, $schema->getJsonSchema()), sprintf( '%s < %d', - 'count($this->_rawModelDataInput) - 1', + 'count($this->rawModelDataInput) - 1', $json['minProperties'], ), MinPropertiesException::class, diff --git a/src/SchemaProcessor/PostProcessor/EnumFilter.php b/src/SchemaProcessor/PostProcessor/EnumFilter.php index 2467675b..47b0b8bc 100644 --- a/src/SchemaProcessor/PostProcessor/EnumFilter.php +++ b/src/SchemaProcessor/PostProcessor/EnumFilter.php @@ -9,11 +9,6 @@ class EnumFilter implements TransformingFilterInterface { - public function getAcceptedTypes(): array - { - return ['string', 'integer', 'number', 'boolean', 'null']; - } - public function getToken(): string { return 'php_model_generator_enum'; diff --git a/src/SchemaProcessor/PostProcessor/Internal/CompositionRequiredPromotionPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/CompositionRequiredPromotionPostProcessor.php index 519642b1..a5622c1b 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/CompositionRequiredPromotionPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/CompositionRequiredPromotionPostProcessor.php @@ -11,7 +11,7 @@ use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; use PHPModelGenerator\Model\Validator\ComposedPropertyValidator; use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator; -use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor; +use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; /** @@ -100,7 +100,7 @@ private function collectFromComposed(ComposedPropertyValidator $validator): arra $branches, ); - if (is_a($validator->getCompositionProcessor(), AllOfProcessor::class, true)) { + if (is_a($validator->getCompositionProcessor(), AllOfValidatorFactory::class, true)) { return array_values(array_unique(array_merge(...$requiredPerBranch))); } diff --git a/src/SchemaProcessor/PostProcessor/Internal/ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor.php index 251356a1..d7f5df36 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor.php @@ -60,7 +60,7 @@ public function getCode(PropertyInterface $property, bool $batchUpdate = false): // TODO: validators return sprintf( ' - $modelData = array_merge($this->_rawModelDataInput, ["%s" => $value]); + $modelData = array_merge($this->rawModelDataInput, ["%s" => $value]); $this->executeBaseValidators($modelData); ', $property->getName(), diff --git a/src/SchemaProcessor/PostProcessor/Internal/PatternPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/PatternPropertiesPostProcessor.php index 8a7fd56d..b17bdba6 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/PatternPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/PatternPropertiesPostProcessor.php @@ -146,7 +146,7 @@ public function getCode(): string foreach ($matchingProperties as $matchingProperty) { $code .= sprintf( - '$this->_patternProperties["%s"]["%s"] = &$this->%s;' . PHP_EOL, + '$this->patternProperties["%s"]["%s"] = &$this->%s;' . PHP_EOL, $hash, $matchingProperty->getName(), $matchingProperty->getAttribute(true), diff --git a/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php index ef620d77..f111ed23 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php @@ -231,7 +231,7 @@ private function addSkipNotProvidedPropertiesMap( } $skipNotProvidedValues = array_map( - static fn(PropertyInterface $property): string => $property->getAttribute(true), + static fn(PropertyInterface $property): string => $property->getName(), array_filter( $schema->getProperties(), static fn(PropertyInterface $property): bool => diff --git a/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php new file mode 100644 index 00000000..af973637 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php @@ -0,0 +1,159 @@ +getProperties() as $property) { + $this->processProperty($property, $schema, $generatorConfiguration); + } + + // Also process validation properties for additional/pattern property validators. + // These properties are not in $schema->getProperties() and would otherwise be missed. + foreach ($schema->getBaseValidators() as $validator) { + if ( + $validator instanceof AdditionalPropertiesValidator + || $validator instanceof PatternPropertiesValidator + ) { + $this->processProperty($validator->getValidationProperty(), $schema, $generatorConfiguration); + } + } + } + + /** + * @throws ReflectionException + */ + private function processProperty( + PropertyInterface $property, + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + ): void { + // Find the FilterValidator whose filter implements TransformingFilterInterface. + $transformingFilterValidator = null; + foreach ($property->getValidators() as $propertyValidator) { + $validator = $propertyValidator->getValidator(); + if ( + $validator instanceof FilterValidator + && $validator->getFilter() instanceof TransformingFilterInterface + ) { + $transformingFilterValidator = $validator; + break; + } + } + + if ($transformingFilterValidator === null) { + return; + } + + /** @var TransformingFilterInterface $filter */ + $filter = $transformingFilterValidator->getFilter(); + + $returnTypeNames = FilterReflection::getReturnTypeNames($filter, $property); + $returnNullable = FilterReflection::isReturnNullable($filter); + + if (empty($returnTypeNames) && !$returnNullable) { + return; + } + + // Sole owner of type-check extension: replace TypeCheckValidator with + // PassThroughTypeCheckValidator that also accepts transformed types. + if (!empty($returnTypeNames)) { + (new FilterProcessor())->extendTypeCheckValidatorToAllowTransformedValue($property, $returnTypeNames); + } + + // Compute output type using the bypass formula. + $acceptedTypes = FilterReflection::getAcceptedTypes($filter, $property); + $baseType = $property->getType(); + + if (empty($acceptedTypes)) { + // Filter accepts all types → nothing bypasses. + $bypassNames = []; + $bypassNullable = false; + } else { + $nonNullAccepted = array_values( + array_filter($acceptedTypes, static fn(string $type): bool => $type !== 'null'), + ); + $hasNullAccepted = in_array('null', $acceptedTypes, true); + + if ($baseType !== null) { + $bypassNames = array_values(array_diff($baseType->getNames(), $nonNullAccepted)); + $bypassNullable = ($baseType->isNullable() === true) && !$hasNullAccepted; + } else { + $bypassNames = []; + $bypassNullable = false; + } + } + + $outputNames = array_values(array_unique(array_merge($bypassNames, $returnTypeNames))); + $outputNullable = $bypassNullable || $returnNullable; + + // Register used classes for non-primitive return types. This must happen unconditionally + // because FilterProcessor may have already set the output type eagerly (when base type was + // known at filter-processing time), in which case $newReturnTypeNames would be empty and + // setType is correctly skipped — but addUsedClass must still be called. + foreach ($returnTypeNames as $typeName) { + if (!TypeCheck::isPrimitive($typeName)) { + $schema->addUsedClass($typeName); + } + } + + // Only update the property type when there are return type names that are new relative + // to the base type. Without a base type there is nothing to extend. + $baseNames = $baseType !== null ? $baseType->getNames() : []; + $newReturnTypeNames = array_values(array_diff($returnTypeNames, $baseNames)); + + if (!empty($newReturnTypeNames) && $baseType !== null) { + $renderHelper = new RenderHelper($generatorConfiguration); + + $outputTypeNames = array_map( + static fn(string $name): string => $renderHelper->getSimpleClassName($name), + $outputNames, + ); + + $property->setType( + $property->getType(), + new PropertyType($outputTypeNames, $outputNullable), + ); + } + } +} diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperties.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperties.phptpl index 46e7f8b5..98f46f55 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperties.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperties.phptpl @@ -5,5 +5,5 @@ */ public function getAdditionalProperties(): array { - return $this->_additionalProperties; + return $this->additionalProperties; } diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperty.phptpl index 4ed93d7c..19256cf0 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/GetAdditionalProperty.phptpl @@ -7,5 +7,5 @@ */ public function getAdditionalProperty(string $property): {% if validationProperty %}{{ viewHelper.getType(validationProperty, true) }}{% else %}mixed{% endif %} { - return $this->_additionalProperties[$property] ?? null; + return $this->additionalProperties[$property] ?? null; } diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl index 85f0aab2..718bdb84 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl @@ -10,22 +10,22 @@ */ public function removeAdditionalProperty(string $property): bool { - if (isset($this->_patternProperties)) { - foreach ($this->_patternProperties as $patternHash => $_) { - if (isset($this->_patternProperties[$patternHash][$property])) { - unset($this->_patternProperties[$patternHash][$property]); - unset($this->_rawModelDataInput[$property]); + if (isset($this->patternProperties)) { + foreach ($this->patternProperties as $patternHash => $_) { + if (isset($this->patternProperties[$patternHash][$property])) { + unset($this->patternProperties[$patternHash][$property]); + unset($this->rawModelDataInput[$property]); } } } - if (!array_key_exists($property, $this->_additionalProperties)) { + if (!array_key_exists($property, $this->additionalProperties)) { return false; } {% if minPropertyValidator %} {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} if ({{ minPropertyValidator.getCheck() }}) { @@ -33,14 +33,14 @@ public function removeAdditionalProperty(string $property): bool } {% if generatorConfiguration.collectErrors() %} - if (count($this->_errorRegistry->getErrors())) { - throw $this->_errorRegistry; + if (count($this->errorRegistry->getErrors())) { + throw $this->errorRegistry; } {% endif %} {% endif %} - unset($this->_rawModelDataInput[$property]); - unset($this->_additionalProperties[$property]); + unset($this->rawModelDataInput[$property]); + unset($this->additionalProperties[$property]); return true; } diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl index 5decd32b..08d9b51b 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl @@ -17,7 +17,7 @@ public function setAdditionalProperty( throw new RegularPropertyAsAdditionalPropertyException($value, $property, self::class); } - if (isset($this->_additionalProperties[$property]) && $this->_additionalProperties[$property] === $value) { + if (isset($this->additionalProperties[$property]) && $this->additionalProperties[$property] === $value) { return $this; } @@ -25,22 +25,22 @@ public function setAdditionalProperty( {% if schema.getBaseValidators() %} {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} $addedProperty = [$property => $value]; $this->executeBaseValidators($addedProperty); {% if generatorConfiguration.collectErrors() %} - if (count($this->_errorRegistry->getErrors())) { - throw $this->_errorRegistry; + if (count($this->errorRegistry->getErrors())) { + throw $this->errorRegistry; } {% endif %} {% else %} - $this->_additionalProperties[$property] = $value; + $this->additionalProperties[$property] = $value; {% endif %} - $this->_rawModelDataInput[$property] = $value; + $this->rawModelDataInput[$property] = $value; {% if validationProperty %}{{ schemaHookResolver.resolveSetterAfterValidationHook(validationProperty) }}{% endif %} diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/UpdateAdditionalProperties.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/UpdateAdditionalProperties.phptpl index 808a28b1..bd86e3df 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/UpdateAdditionalProperties.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/UpdateAdditionalProperties.phptpl @@ -8,7 +8,7 @@ } {% endif %} - $this->_additionalProperties[$propertyKey] = $value[$propertyKey]; + $this->additionalProperties[$propertyKey] = $value[$propertyKey]; } return false; diff --git a/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl b/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl index eb828761..1c4c9f02 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/BuilderClass.phptpl @@ -19,19 +19,19 @@ declare(strict_types = 1); class {{ class }}Builder implements BuilderInterface { /** @var array */ - protected $_rawModelDataInput = []; + protected $rawModelDataInput = []; {% if generatorConfiguration.collectErrors() %} /** @var {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }} */ - protected $_errorRegistry; + protected $errorRegistry; {% endif %} public function __construct(array $rawModelDataInput = []) { - $this->_rawModelDataInput = $rawModelDataInput; + $this->rawModelDataInput = $rawModelDataInput; {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} } @@ -42,7 +42,7 @@ class {{ class }}Builder implements BuilderInterface */ public function getRawModelDataInput(): array { - return $this->_rawModelDataInput; + return $this->rawModelDataInput; } /** @@ -51,7 +51,7 @@ class {{ class }}Builder implements BuilderInterface public function validate(): {{ class }} { $this->_array_walk_recursive_real( - $this->_rawModelDataInput, + $this->rawModelDataInput, function (&$property): void { if ($property instanceof BuilderInterface) { $property = $property->getRawModelDataInput(); @@ -59,7 +59,7 @@ class {{ class }}Builder implements BuilderInterface }, ); - return new {{ class }}($this->_rawModelDataInput); + return new {{ class }}($this->rawModelDataInput); } // PHPs builtin array_walk_recursive doesn't handle newly inserted arrays which we need to visit @@ -85,7 +85,7 @@ class {{ class }}Builder implements BuilderInterface */ public function get{{ viewHelper.ucfirst(property.getAttribute()) }}(): {{ viewHelper.getType(property, false, true) }} { - return $this->_rawModelDataInput['{{ property.getName() }}'] ?? null; + return $this->rawModelDataInput['{{ property.getName() }}'] ?? null; } /** @@ -100,8 +100,8 @@ class {{ class }}Builder implements BuilderInterface public function set{{ viewHelper.ucfirst(property.getAttribute()) }}( {{ viewHelper.getType(property) }} ${{ property.getAttribute(true) }} ): static { - if (array_key_exists('{{ property.getName() }}', $this->_rawModelDataInput) - && $this->_rawModelDataInput['{{ property.getName() }}'] === ${{ property.getAttribute(true) }} + if (array_key_exists('{{ property.getName() }}', $this->rawModelDataInput) + && $this->rawModelDataInput['{{ property.getName() }}'] === ${{ property.getAttribute(true) }} ) { return $this; } @@ -113,12 +113,12 @@ class {{ class }}Builder implements BuilderInterface {% endforeach %} {% if property.getOrderedValidators() and generatorConfiguration.collectErrors() %} - if ($this->_errorRegistry->getErrors()) { - throw $this->_errorRegistry; + if ($this->errorRegistry->getErrors()) { + throw $this->errorRegistry; } {% endif %} - $this->_rawModelDataInput['{{ property.getName() }}'] = $value; + $this->rawModelDataInput['{{ property.getName() }}'] = $value; return $this; } diff --git a/src/SchemaProcessor/PostProcessor/Templates/CompositionValidation.phptpl b/src/SchemaProcessor/PostProcessor/Templates/CompositionValidation.phptpl index 7baf14b7..9dd9bd49 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/CompositionValidation.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/CompositionValidation.phptpl @@ -8,7 +8,7 @@ private function validateComposition_{{ index }}(array &$modifiedModelData): void { $validatorIndex = {{ index }}; - $value = $modelData = array_merge($this->_rawModelDataInput, $modifiedModelData); + $value = $modelData = array_merge($this->rawModelDataInput, $modifiedModelData); {{ validator.getValidatorSetUp() }} if ({{ validator.getCheck() }}) { diff --git a/src/SchemaProcessor/PostProcessor/Templates/PatternProperties/GetPatternProperties.phptpl b/src/SchemaProcessor/PostProcessor/Templates/PatternProperties/GetPatternProperties.phptpl index 43e9a4f0..19c07d1c 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/PatternProperties/GetPatternProperties.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/PatternProperties/GetPatternProperties.phptpl @@ -11,9 +11,9 @@ public function getPatternProperties(string $key): array { $hash = md5($key); - if (!isset($this->_patternProperties[$hash])) { + if (!isset($this->patternProperties[$hash])) { throw new UnknownPatternPropertyException("Tried to access unknown pattern properties with key $key"); } - return $this->_patternProperties[$hash]; + return $this->patternProperties[$hash]; } diff --git a/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl index d2e1e43b..c45b797c 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl @@ -13,12 +13,12 @@ public function populate(array $modelData): self $rollbackValues = []; {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% else %} try { {% endif %} - foreach (['_additionalProperties', '_patternProperties'] as $property) { + foreach (['additionalProperties', 'patternProperties'] as $property) { if (isset($this->{$property})) { $rollbackValues[$property] = $this->{$property}; } @@ -42,12 +42,12 @@ public function populate(array $modelData): self {% endforeach %} {% if generatorConfiguration.collectErrors() %} - if (count($this->_errorRegistry->getErrors())) { + if (count($this->errorRegistry->getErrors())) { foreach ($rollbackValues as $property => $value) { $this->{$property} = $value; } - throw $this->_errorRegistry; + throw $this->errorRegistry; } {% else %} } catch (ValidationException $exception) { @@ -59,7 +59,7 @@ public function populate(array $modelData): self } {% endif %} - $this->_rawModelDataInput = array_merge($this->_rawModelDataInput, $modelData); + $this->rawModelDataInput = array_merge($this->rawModelDataInput, $modelData); {% foreach schema.getProperties() as property %} {% if not property.isInternal() and schemaHookResolver.resolveSetterAfterValidationHook(property) %} diff --git a/src/SchemaProcessor/PostProcessor/Templates/Serialization/AdditionalPropertiesSerializer.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Serialization/AdditionalPropertiesSerializer.phptpl index 05ac3d6c..339b2e86 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/Serialization/AdditionalPropertiesSerializer.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/Serialization/AdditionalPropertiesSerializer.phptpl @@ -2,13 +2,13 @@ protected function serializeAdditionalProperties(int $depth, array $except): arr { {% if serializerClass %} $serializedValues = []; - foreach ($this->_additionalProperties as $key => $value) { + foreach ($this->additionalProperties as $key => $value) { $serializedValues[$key] = \{{ serializerClass }}::{{ serializerMethod }}($value, {{ serializerOptions }}); } {% endif %} - return $this->_getSerializedValue( - {% if serializerClass %}$serializedValues{% else %}$this->_additionalProperties{% endif %}, + return $this->getSerializedValue( + {% if serializerClass %}$serializedValues{% else %}$this->additionalProperties{% endif %}, $depth, $except ); diff --git a/src/SchemaProcessor/PostProcessor/Templates/Serialization/PatternPropertiesSerializer.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Serialization/PatternPropertiesSerializer.phptpl index dd6b5b3f..fd219bc4 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/Serialization/PatternPropertiesSerializer.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/Serialization/PatternPropertiesSerializer.phptpl @@ -2,16 +2,16 @@ protected function serializePatternProperties(int $depth, array $except): array { $serializedPatternProperties = []; - foreach ($this->_patternProperties as $patternKey => $properties) { - if ($customSerializer = $this->_getCustomSerializerMethod($patternKey)) { + foreach ($this->patternProperties as $patternKey => $properties) { + if ($customSerializer = $this->getCustomSerializerMethod($patternKey)) { foreach ($this->{$customSerializer}() as $propertyKey => $value) { - $serializedPatternProperties[$propertyKey] = $this->_getSerializedValue($value, $depth, $except); + $serializedPatternProperties[$propertyKey] = $this->getSerializedValue($value, $depth, $except); } continue; } foreach ($properties as $propertyKey => $value) { - $serializedPatternProperties[$propertyKey] = $this->_getSerializedValue($value, $depth, $except); + $serializedPatternProperties[$propertyKey] = $this->getSerializedValue($value, $depth, $except); } } diff --git a/src/SchemaProcessor/PostProcessor/Templates/Serialization/PatternPropertyTransformingFilterSerializer.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Serialization/PatternPropertyTransformingFilterSerializer.phptpl index 42c8046d..a8ada53d 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/Serialization/PatternPropertyTransformingFilterSerializer.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/Serialization/PatternPropertyTransformingFilterSerializer.phptpl @@ -4,7 +4,7 @@ protected function serialize{{ viewHelper.ucfirst(key) }}(): array { $serialized = []; - foreach ($this->_patternProperties['{{ key }}'] as $propertyKey => $value) { + foreach ($this->patternProperties['{{ key }}'] as $propertyKey => $value) { $serialized[$propertyKey] = \{{ serializerClass }}::{{ serializerMethod }}($value, {{ serializerOptions }}); } diff --git a/src/SchemaProcessor/SchemaProcessor.php b/src/SchemaProcessor/SchemaProcessor.php index 18bc3a1c..80c3d0ec 100644 --- a/src/SchemaProcessor/SchemaProcessor.php +++ b/src/SchemaProcessor/SchemaProcessor.php @@ -14,12 +14,17 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\SchemaDefinition\SchemaDefinitionDictionary; +use PHPModelGenerator\Model\Validator; +use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; +use PHPModelGenerator\Model\Validator\ComposedPropertyValidator; +use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator; +use PHPModelGenerator\Model\Validator\PropertyTemplateValidator; +use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Composition\ComposedPropertiesValidatorFactoryInterface; use PHPModelGenerator\PropertyProcessor\Decorator\Property\ObjectInstantiationDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator; -use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; -use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProvider\SchemaProviderInterface; /** @@ -173,8 +178,7 @@ protected function generateModel( $json = $jsonSchema->getJson(); $json['type'] = 'base'; - (new PropertyFactory(new PropertyProcessorFactory()))->create( - new PropertyMetaDataCollection($jsonSchema->getJson()['required'] ?? []), + (new PropertyFactory())->create( $this, $schema, $className, @@ -449,6 +453,203 @@ public function processTopLevelSchema(JsonSchema $jsonSchema): ?Schema return $schema; } + /** + * Transfer properties of composed properties to the given schema to offer a complete model + * including all composed properties. + * + * This is an internal pipeline mechanic (Q5.1): not a JSON Schema keyword and therefore not + * a Draft modifier. It is called as an explicit post-step from generateModel after all Draft + * modifiers have run on the root-level BaseProperty. + * + * @throws SchemaException + */ + public function transferComposedPropertiesToSchema(PropertyInterface $property, Schema $schema): void + { + foreach ($property->getValidators() as $validator) { + $validator = $validator->getValidator(); + + if (!is_a($validator, AbstractComposedPropertyValidator::class)) { + continue; + } + + // If the transferred validator of the composed property is also a composed property + // strip the nested composition validations from the added validator. The nested + // composition will be validated in the object generated for the nested composition + // which will be executed via an instantiation. Consequently, the validation must not + // be executed in the outer composition. + $schema->addBaseValidator( + ($validator instanceof ComposedPropertyValidator) + ? $validator->withoutNestedCompositionValidation() + : $validator, + ); + + if ( + !is_a( + $validator->getCompositionProcessor(), + ComposedPropertiesValidatorFactoryInterface::class, + true, + ) + ) { + continue; + } + + $branchesForValidator = $validator instanceof ConditionalPropertyValidator + ? $validator->getConditionBranches() + : $validator->getComposedProperties(); + + $totalBranches = count($branchesForValidator); + $resolvedPropertiesCallbacks = 0; + $seenBranchPropertyNames = []; + + foreach ($validator->getComposedProperties() as $composedProperty) { + $composedProperty->onResolve(function () use ( + $composedProperty, + $property, + $validator, + $branchesForValidator, + $totalBranches, + $schema, + &$resolvedPropertiesCallbacks, + &$seenBranchPropertyNames, + ): void { + if (!$composedProperty->getNestedSchema()) { + throw new SchemaException( + sprintf( + "No nested schema for composed property %s in file %s found", + $property->getName(), + $property->getJsonSchema()->getFile(), + ) + ); + } + + $isBranchForValidator = in_array($composedProperty, $branchesForValidator, true); + + $composedProperty->getNestedSchema()->onAllPropertiesResolved( + function () use ( + $composedProperty, + $validator, + $isBranchForValidator, + $totalBranches, + $schema, + &$resolvedPropertiesCallbacks, + &$seenBranchPropertyNames, + ): void { + foreach ($composedProperty->getNestedSchema()->getProperties() as $branchProperty) { + $schema->addProperty( + $this->cloneTransferredProperty( + $branchProperty, + $composedProperty, + $validator, + ), + $validator->getCompositionProcessor(), + ); + + $composedProperty->appendAffectedObjectProperty($branchProperty); + $seenBranchPropertyNames[$branchProperty->getName()] = true; + } + + if ($isBranchForValidator && ++$resolvedPropertiesCallbacks === $totalBranches) { + foreach (array_keys($seenBranchPropertyNames) as $branchPropertyName) { + $schema->getPropertyMerger()->checkForTotalConflict( + $branchPropertyName, + $totalBranches, + ); + } + } + }, + ); + }); + } + } + } + + /** + * Clone the provided property to transfer it to a schema. Sets the nullability and required + * flag based on the composition processor used to set up the composition. Widens the type to + * mixed when the property is exclusive to one anyOf/oneOf branch and at least one other branch + * allows additional properties, preventing TypeError when raw input values of an arbitrary + * type are stored in the property slot. + */ + private function cloneTransferredProperty( + PropertyInterface $property, + CompositionPropertyDecorator $sourceBranch, + AbstractComposedPropertyValidator $validator, + ): PropertyInterface { + $compositionProcessor = $validator->getCompositionProcessor(); + + $transferredProperty = (clone $property) + ->filterValidators(static fn(Validator $v): bool => + is_a($v->getValidator(), PropertyTemplateValidator::class)); + + if (!is_a($compositionProcessor, AllOfValidatorFactory::class, true)) { + $transferredProperty->setRequired(false); + + if ($transferredProperty->getType()) { + $transferredProperty->setType( + new PropertyType($transferredProperty->getType()->getNames(), true), + new PropertyType($transferredProperty->getType(true)->getNames(), true), + ); + } + + $wideningBranches = $validator instanceof ConditionalPropertyValidator + ? $validator->getConditionBranches() + : $validator->getComposedProperties(); + + if ($this->exclusiveBranchPropertyNeedsWidening($property->getName(), $sourceBranch, $wideningBranches)) { + $transferredProperty->setType(null, null, reset: true); + } + } + + return $transferredProperty; + } + + /** + * Returns true when the property named $propertyName is exclusive to $sourceBranch and at + * least one other anyOf/oneOf branch allows additional properties (i.e. does NOT declare + * additionalProperties: false). In that case the property slot can receive an + * arbitrarily-typed raw input value from a non-matching branch, so the type hint is removed. + * + * Returns false when the property appears in another branch too (Schema::addProperty handles + * that via type merging) or when all other branches have additionalProperties: false (making + * the property mutually exclusive with the other branches' properties). + * + * @param CompositionPropertyDecorator[] $allBranches + */ + private function exclusiveBranchPropertyNeedsWidening( + string $propertyName, + CompositionPropertyDecorator $sourceBranch, + array $allBranches, + ): bool { + foreach ($allBranches as $branch) { + if ($branch === $sourceBranch) { + continue; + } + + $branchPropertyNames = $branch->getNestedSchema() + ? array_map( + static fn(PropertyInterface $p): string => $p->getName(), + $branch->getNestedSchema()->getProperties(), + ) + : []; + + if (in_array($propertyName, $branchPropertyNames, true)) { + return false; + } + } + + foreach ($allBranches as $branch) { + if ($branch === $sourceBranch) { + continue; + } + + if (($branch->getBranchSchema()->getJson()['additionalProperties'] ?? true) !== false) { + return true; + } + } + + return false; + } + private function getTargetFileName(string $classPath, string $className): string { return join( diff --git a/src/SchemaProvider/OpenAPIv3Provider.php b/src/SchemaProvider/OpenAPIv3Provider.php index 33afa80f..2fee7861 100644 --- a/src/SchemaProvider/OpenAPIv3Provider.php +++ b/src/SchemaProvider/OpenAPIv3Provider.php @@ -54,7 +54,11 @@ public function getSchemas(): iterable $schema['$id'] = $schemaKey; } - yield new JsonSchema($this->sourceFile, array_merge($this->openAPIv3Spec, $schema)); + yield new JsonSchema( + $this->sourceFile, + array_merge($this->openAPIv3Spec, $schema), + "/components/schemas/$schemaKey" + ); } } diff --git a/src/SchemaProvider/SingleFileProvider.php b/src/SchemaProvider/SingleFileProvider.php new file mode 100644 index 00000000..5b316096 --- /dev/null +++ b/src/SchemaProvider/SingleFileProvider.php @@ -0,0 +1,49 @@ +sourceFile = realpath($this->sourceFile) ?: $this->sourceFile; + $jsonSchemaContent = @file_get_contents($this->sourceFile); + $decoded = $jsonSchemaContent !== false ? json_decode($jsonSchemaContent, true) : null; + + if (!$decoded) { + throw new SchemaException("Invalid JSON-Schema file {$this->sourceFile}"); + } + + $this->schema = $decoded; + } + + /** + * @inheritDoc + */ + public function getSchemas(): iterable + { + yield new JsonSchema($this->sourceFile, $this->schema); + } + + /** + * @inheritDoc + */ + public function getBaseDirectory(): string + { + return dirname($this->sourceFile); + } +} diff --git a/src/Templates/Decorator/ObjectInstantiationDecorator.phptpl b/src/Templates/Decorator/ObjectInstantiationDecorator.phptpl index 5875e38b..85cd04d4 100644 --- a/src/Templates/Decorator/ObjectInstantiationDecorator.phptpl +++ b/src/Templates/Decorator/ObjectInstantiationDecorator.phptpl @@ -7,7 +7,7 @@ {% else %} {% if generatorConfiguration.collectErrors() %} foreach($instantiationException->getErrors() as $nestedValidationError) { - $this->_errorRegistry->addError($nestedValidationError); + $this->errorRegistry->addError($nestedValidationError); } {% else %} throw $instantiationException; diff --git a/src/Templates/Model.phptpl b/src/Templates/Model.phptpl index d524cd03..b680b128 100644 --- a/src/Templates/Model.phptpl +++ b/src/Templates/Model.phptpl @@ -37,12 +37,14 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v /**{% if viewHelper.getTypeHintAnnotation(property, true) %} @var {{ viewHelper.getTypeHintAnnotation(property, true) }}{% endif %}{% if property.getDescription() %} {{ property.getDescription() }}{% endif %} */ {% if property.isInternal() %}private{% else %}protected{% endif %} ${{ property.getAttribute(true) }}{% if not viewHelper.isNull(property.getDefaultValue()) %} = {{ property.getDefaultValue() }}{% endif %}; {% endforeach %} + #[Internal] /** @var array */ - protected $_rawModelDataInput = []; + protected $rawModelDataInput = []; {% if generatorConfiguration.collectErrors() %} + #[Internal] /** @var {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }} Collect all validation errors */ - protected $_errorRegistry; + protected $errorRegistry; {% endif %} /** @@ -55,7 +57,7 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v public function __construct(array $rawModelDataInput = []) { {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} {{ schemaHookResolver.resolveConstructorBeforeValidationHook() }} @@ -71,12 +73,12 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v {% endforeach %} {% if generatorConfiguration.collectErrors() %} - if (count($this->_errorRegistry->getErrors())) { - throw $this->_errorRegistry; + if (count($this->errorRegistry->getErrors())) { + throw $this->errorRegistry; } {% endif %} - $this->_rawModelDataInput = $rawModelDataInput; + $this->rawModelDataInput = $rawModelDataInput; {{ schemaHookResolver.resolveConstructorAfterValidationHook() }} } @@ -103,7 +105,7 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v */ public function getRawModelDataInput(): array { - return $this->_rawModelDataInput; + return $this->rawModelDataInput; } {% foreach schema.getProperties() as property %} @@ -142,7 +144,7 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v $value = $modelData['{{ property.getName() }}'] = ${{ property.getAttribute(true) }}; {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} {{ schemaHookResolver.resolveSetterBeforeValidationHook(property) }} @@ -150,13 +152,13 @@ class {{ schema.getClassName() }} {% if schema.getInterfaces() %}implements {{ v $value = $this->validate{{ viewHelper.ucfirst(property.getAttribute()) }}($value, $modelData); {% if generatorConfiguration.collectErrors() %} - if ($this->_errorRegistry->getErrors()) { - throw $this->_errorRegistry; + if ($this->errorRegistry->getErrors()) { + throw $this->errorRegistry; } {% endif %} $this->{{ property.getAttribute(true) }} = $value; - $this->_rawModelDataInput['{{ property.getName() }}'] = ${{ property.getAttribute(true) }}; + $this->rawModelDataInput['{{ property.getName() }}'] = ${{ property.getAttribute(true) }}; {{ schemaHookResolver.resolveSetterAfterValidationHook(property) }} diff --git a/src/Templates/Validator/AdditionalProperties.phptpl b/src/Templates/Validator/AdditionalProperties.phptpl index 96c93e64..7df93496 100644 --- a/src/Templates/Validator/AdditionalProperties.phptpl +++ b/src/Templates/Validator/AdditionalProperties.phptpl @@ -1,9 +1,9 @@ (function () use ($properties, &$invalidProperties, $modelData) { {% if generatorConfiguration.collectErrors() %} - $originalErrorRegistry = $this->_errorRegistry; + $originalErrorRegistry = $this->errorRegistry; {% endif %} {% if collectAdditionalProperties %} - $rollbackValues = $this->_additionalProperties; + $rollbackValues = $this->additionalProperties; {% endif %} foreach (array_diff(array_keys($properties), {{ additionalProperties }}) as $propertyKey) { @@ -19,7 +19,7 @@ $value = $properties[$propertyKey]; {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} {{ viewHelper.resolvePropertyDecorator(validationProperty) }} @@ -29,13 +29,13 @@ {% endforeach %} {% if generatorConfiguration.collectErrors() %} - if ($this->_errorRegistry->getErrors()) { - $invalidProperties[$propertyKey] = $this->_errorRegistry->getErrors(); + if ($this->errorRegistry->getErrors()) { + $invalidProperties[$propertyKey] = $this->errorRegistry->getErrors(); } {% endif %} {% if collectAdditionalProperties %} - $this->_additionalProperties[$propertyKey] = $value; + $this->additionalProperties[$propertyKey] = $value; {% endif %} } catch (\Exception $e) { // collect all errors concerning invalid additional properties @@ -46,12 +46,12 @@ } {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = $originalErrorRegistry; + $this->errorRegistry = $originalErrorRegistry; {% endif %} {% if collectAdditionalProperties %} if (!empty($invalidProperties)) { - $this->_additionalProperties = $rollbackValues; + $this->additionalProperties = $rollbackValues; } {% endif %} diff --git a/src/Templates/Validator/ArrayContains.phptpl b/src/Templates/Validator/ArrayContains.phptpl index de5567a2..633ebbee 100644 --- a/src/Templates/Validator/ArrayContains.phptpl +++ b/src/Templates/Validator/ArrayContains.phptpl @@ -4,13 +4,13 @@ is_array($value) && (function (&$items) { } {% if generatorConfiguration.collectErrors() %} - $originalErrorRegistry = $this->_errorRegistry; + $originalErrorRegistry = $this->errorRegistry; {% endif %} foreach ($items as &$value) { try { {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} {{ viewHelper.resolvePropertyDecorator(property) }} @@ -20,11 +20,11 @@ is_array($value) && (function (&$items) { {% endforeach %} {% if generatorConfiguration.collectErrors() %} - if ($this->_errorRegistry->getErrors()) { + if ($this->errorRegistry->getErrors()) { continue; } - $this->_errorRegistry = $originalErrorRegistry; + $this->errorRegistry = $originalErrorRegistry; {% endif %} // one matched item is enough to pass the contains check @@ -35,7 +35,7 @@ is_array($value) && (function (&$items) { } {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = $originalErrorRegistry; + $this->errorRegistry = $originalErrorRegistry; {% endif %} return true; diff --git a/src/Templates/Validator/ArrayItem.phptpl b/src/Templates/Validator/ArrayItem.phptpl index cdbea591..fff4219a 100644 --- a/src/Templates/Validator/ArrayItem.phptpl +++ b/src/Templates/Validator/ArrayItem.phptpl @@ -1,11 +1,11 @@ is_array($value) && (function (&$items) use (&$invalidItems{{ suffix }}, $modelData) { {% if generatorConfiguration.collectErrors() %} - $originalErrorRegistry = $this->_errorRegistry; + $originalErrorRegistry = $this->errorRegistry; {% endif %} foreach ($items as $index => &$value) { {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} try { @@ -16,8 +16,8 @@ is_array($value) && (function (&$items) use (&$invalidItems{{ suffix }}, $modelD {% endforeach %} {% if generatorConfiguration.collectErrors() %} - if ($this->_errorRegistry->getErrors()) { - $invalidItems{{ suffix }}[$index] = $this->_errorRegistry->getErrors(); + if ($this->errorRegistry->getErrors()) { + $invalidItems{{ suffix }}[$index] = $this->errorRegistry->getErrors(); } {% endif %} } catch (\Exception $e) { @@ -29,7 +29,7 @@ is_array($value) && (function (&$items) use (&$invalidItems{{ suffix }}, $modelD } {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = $originalErrorRegistry; + $this->errorRegistry = $originalErrorRegistry; {% endif %} return !empty($invalidItems{{ suffix }}); diff --git a/src/Templates/Validator/ArrayTuple.phptpl b/src/Templates/Validator/ArrayTuple.phptpl index d6ce49ea..fb183ecc 100644 --- a/src/Templates/Validator/ArrayTuple.phptpl +++ b/src/Templates/Validator/ArrayTuple.phptpl @@ -1,6 +1,6 @@ is_array($value) && (function (&$items) use (&$invalidTuples, $modelData) { {% if generatorConfiguration.collectErrors() %} - $originalErrorRegistry = $this->_errorRegistry; + $originalErrorRegistry = $this->errorRegistry; {% endif %} $index = 0; @@ -12,7 +12,7 @@ is_array($value) && (function (&$items) use (&$invalidTuples, $modelData) { } {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} $value = &$items[$index++]; @@ -23,8 +23,8 @@ is_array($value) && (function (&$items) use (&$invalidTuples, $modelData) { {% endforeach %} {% if generatorConfiguration.collectErrors() %} - if ($this->_errorRegistry->getErrors()) { - $invalidTuples[$index] = $this->_errorRegistry->getErrors(); + if ($this->errorRegistry->getErrors()) { + $invalidTuples[$index] = $this->errorRegistry->getErrors(); } {% endif %} } catch (\Exception $e) { @@ -36,7 +36,7 @@ is_array($value) && (function (&$items) use (&$invalidTuples, $modelData) { {% endforeach %} {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = $originalErrorRegistry; + $this->errorRegistry = $originalErrorRegistry; {% endif %} return !empty($invalidTuples); diff --git a/src/Templates/Validator/ComposedItem.phptpl b/src/Templates/Validator/ComposedItem.phptpl index 7ecd25ec..7acd03b4 100644 --- a/src/Templates/Validator/ComposedItem.phptpl +++ b/src/Templates/Validator/ComposedItem.phptpl @@ -15,11 +15,11 @@ $modifiedValues = []; {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} - $originalPropertyValidationState = $this->_propertyValidationState ?? []; + $originalPropertyValidationState = $this->propertyValidationState ?? []; {% endif %} {% if generatorConfiguration.collectErrors() %} - $originalErrorRegistry = $this->_errorRegistry; + $originalErrorRegistry = $this->errorRegistry; {% endif %} {% foreach compositionProperties as compositionProperty %} @@ -28,7 +28,7 @@ // check if the state of the validator is already known. // 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]) && + isset($this->propertyValidationState[$validatorIndex][$validatorComponentIndex]) && !array_intersect( array_keys($modifiedModelData), [ @@ -39,13 +39,13 @@ ) ) { {% if generatorConfiguration.collectErrors() %} - $compositionErrorCollection[] = $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex]; + $compositionErrorCollection[] = $this->propertyValidationState[$validatorIndex][$validatorComponentIndex]; {% endif %} if ({% if generatorConfiguration.collectErrors() %} - $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex]->getErrors() + $this->propertyValidationState[$validatorIndex][$validatorComponentIndex]->getErrors() {% else %} - $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex] !== true + $this->propertyValidationState[$validatorIndex][$validatorComponentIndex] !== true {% endif %} ) { throw new \Exception(); @@ -55,7 +55,7 @@ {% if generatorConfiguration.collectErrors() %} // collect errors for each composition element - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} {% if not postPropose %} @@ -69,17 +69,17 @@ {% endforeach %} {% if generatorConfiguration.collectErrors() %} - $compositionErrorCollection[] = $this->_errorRegistry; + $compositionErrorCollection[] = $this->errorRegistry; {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} if (isset($validatorIndex)) { - $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex] = $this->_errorRegistry; + $this->propertyValidationState[$validatorIndex][$validatorComponentIndex] = $this->errorRegistry; } {% endif %} // an error inside the composed validation occurred. Throw an exception to count the validity of the // composition item - if ($this->_errorRegistry->getErrors()) { + if ($this->errorRegistry->getErrors()) { throw new \Exception(); } {% endif %} @@ -94,7 +94,7 @@ {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} {% if not generatorConfiguration.collectErrors() %} if (isset($validatorIndex)) { - $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex] = true; + $this->propertyValidationState[$validatorIndex][$validatorComponentIndex] = true; } {% endif %} } @@ -104,7 +104,7 @@ and not generatorConfiguration.collectErrors() %} if (isset($validatorIndex)) { - $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex] = false; + $this->propertyValidationState[$validatorIndex][$validatorComponentIndex] = false; } {% endif %} @@ -130,14 +130,14 @@ {% endif %} {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = $originalErrorRegistry; + $this->errorRegistry = $originalErrorRegistry; {% endif %} $result = !({{ composedValueValidation }}); {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} if ($result) { - $this->_propertyValidationState = $originalPropertyValidationState; + $this->propertyValidationState = $originalPropertyValidationState; } {% endif %} diff --git a/src/Templates/Validator/ConditionalComposedItem.phptpl b/src/Templates/Validator/ConditionalComposedItem.phptpl index 8bb03700..94d5a917 100644 --- a/src/Templates/Validator/ConditionalComposedItem.phptpl +++ b/src/Templates/Validator/ConditionalComposedItem.phptpl @@ -9,8 +9,8 @@ ) { $originalModelData = $value; {% if generatorConfiguration.collectErrors() %} - $originalErrorRegistry = $this->_errorRegistry; - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $originalErrorRegistry = $this->errorRegistry; + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} try { @@ -21,8 +21,8 @@ {% endforeach %} {% if generatorConfiguration.collectErrors() %} - if ($this->_errorRegistry->getErrors()) { - throw $this->_errorRegistry; + if ($this->errorRegistry->getErrors()) { + throw $this->errorRegistry; } {% endif %} } catch (\Exception $e) { @@ -31,7 +31,7 @@ $value = $originalModelData; {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} if (!$ifException) { @@ -44,8 +44,8 @@ {% endforeach %} {% if generatorConfiguration.collectErrors() %} - if ($this->_errorRegistry->getErrors()) { - throw $this->_errorRegistry; + if ($this->errorRegistry->getErrors()) { + throw $this->errorRegistry; } {% endif %} } catch (\Exception $e) { @@ -62,8 +62,8 @@ {% endforeach %} {% if generatorConfiguration.collectErrors() %} - if ($this->_errorRegistry->getErrors()) { - throw $this->_errorRegistry; + if ($this->errorRegistry->getErrors()) { + throw $this->errorRegistry; } {% endif %} } catch (\Exception $e) { @@ -73,7 +73,7 @@ } {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = $originalErrorRegistry; + $this->errorRegistry = $originalErrorRegistry; {% endif %} return $thenException || $elseException; diff --git a/src/Templates/Validator/Filter.phptpl b/src/Templates/Validator/Filter.phptpl index e9e07889..988eaf2b 100644 --- a/src/Templates/Validator/Filter.phptpl +++ b/src/Templates/Validator/Filter.phptpl @@ -1,6 +1,6 @@ {% if skipTransformedValuesCheck %}{{ skipTransformedValuesCheck }} && {% endif %} ( - {% if typeCheck %}{{ typeCheck }} || {% endif %} + {% if typeCheck %}{{ typeCheck }} && {% endif %} (function (&$value) use (&$transformationFailed): bool { // make sure exceptions from the filter are caught and added to the error handling try { diff --git a/src/Templates/Validator/PatternProperties.phptpl b/src/Templates/Validator/PatternProperties.phptpl index 5efc7af4..9d4a09fc 100644 --- a/src/Templates/Validator/PatternProperties.phptpl +++ b/src/Templates/Validator/PatternProperties.phptpl @@ -1,9 +1,9 @@ (function () use ($properties, &$invalidProperties, $modelData) { {% if generatorConfiguration.collectErrors() %} - $originalErrorRegistry = $this->_errorRegistry; + $originalErrorRegistry = $this->errorRegistry; {% endif %} - $rollbackValues = $this->_patternProperties; + $rollbackValues = $this->patternProperties; foreach ($properties as $propertyKey => $value) { $propertyKey = (string) $propertyKey; @@ -13,7 +13,7 @@ } {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} {{ viewHelper.resolvePropertyDecorator(validationProperty) }} @@ -23,13 +23,13 @@ {% endforeach %} {% if generatorConfiguration.collectErrors() %} - if ($this->_errorRegistry->getErrors()) { - $invalidProperties[$propertyKey] = $this->_errorRegistry->getErrors(); + if ($this->errorRegistry->getErrors()) { + $invalidProperties[$propertyKey] = $this->errorRegistry->getErrors(); } {% endif %} - if (!isset($this->_patternPropertiesMap[$propertyKey])) { - $this->_patternProperties['{{ patternHash }}'][$propertyKey] = $value; + if (!isset($this->patternPropertiesMap[$propertyKey])) { + $this->patternProperties['{{ patternHash }}'][$propertyKey] = $value; } } catch (\Exception $e) { // collect all errors concerning invalid pattern properties @@ -40,11 +40,11 @@ } {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = $originalErrorRegistry; + $this->errorRegistry = $originalErrorRegistry; {% endif %} if (!empty($invalidProperties)) { - $this->_patternProperties = $rollbackValues; + $this->patternProperties = $rollbackValues; } return !empty($invalidProperties); diff --git a/src/Templates/Validator/PropertyNames.phptpl b/src/Templates/Validator/PropertyNames.phptpl index fb63a6f8..9baca589 100644 --- a/src/Templates/Validator/PropertyNames.phptpl +++ b/src/Templates/Validator/PropertyNames.phptpl @@ -1,6 +1,6 @@ (function ($propertyNames) use (&$invalidProperties) { {% if generatorConfiguration.collectErrors() %} - $originalErrorRegistry = $this->_errorRegistry; + $originalErrorRegistry = $this->errorRegistry; {% endif %} foreach ($propertyNames as $value) { @@ -9,7 +9,7 @@ try { {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} {% foreach nameValidationProperty.getOrderedValidators() as validator %} @@ -17,8 +17,8 @@ {% endforeach %} {% if generatorConfiguration.collectErrors() %} - if ($this->_errorRegistry->getErrors()) { - $invalidProperties[$value] = $this->_errorRegistry->getErrors(); + if ($this->errorRegistry->getErrors()) { + $invalidProperties[$value] = $this->errorRegistry->getErrors(); } {% endif %} } catch (\Exception $e) { @@ -30,7 +30,7 @@ } {% if generatorConfiguration.collectErrors() %} - $this->_errorRegistry = $originalErrorRegistry; + $this->errorRegistry = $originalErrorRegistry; {% endif %} return !empty($invalidProperties); diff --git a/src/Templates/Validator/SchemaDependency.phptpl b/src/Templates/Validator/SchemaDependency.phptpl index 33d4c6fb..76969e8f 100644 --- a/src/Templates/Validator/SchemaDependency.phptpl +++ b/src/Templates/Validator/SchemaDependency.phptpl @@ -1,7 +1,7 @@ array_key_exists('{{ property.getName() }}', $modelData) && (function () use ($modelData, &$dependencyException) { {% if generatorConfiguration.collectErrors() %} - $originalErrorRegistry = $this->_errorRegistry; - $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + $originalErrorRegistry = $this->errorRegistry; + $this->errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% else %} try { {% endif %} @@ -10,11 +10,11 @@ array_key_exists('{{ property.getName() }}', $modelData) && (function () use ($m {{ viewHelper.resolvePropertyDecorator(nestedProperty) }} {% if generatorConfiguration.collectErrors() %} - if ($this->_errorRegistry->getErrors()) { - $dependencyException = $this->_errorRegistry; + if ($this->errorRegistry->getErrors()) { + $dependencyException = $this->errorRegistry; } - $this->_errorRegistry = $originalErrorRegistry; + $this->errorRegistry = $originalErrorRegistry; {% else %} } catch (\Exception $e) { $dependencyException = $e; diff --git a/src/Utils/FilterReflection.php b/src/Utils/FilterReflection.php new file mode 100644 index 00000000..a5436f31 --- /dev/null +++ b/src/Utils/FilterReflection.php @@ -0,0 +1,179 @@ +getFilter()[0], $filter->getFilter()[1]))->getParameters(); + + if (empty($params) || $params[0]->getType() === null) { + throw new InvalidFilterException( + sprintf( + 'Filter %s must declare a type hint on its first parameter for property %s in file %s', + $filter->getToken(), + $property->getName(), + $property->getJsonSchema()->getFile(), + ), + ); + } + + $type = $params[0]->getType(); + + if ($type instanceof ReflectionNamedType) { + if ($type->getName() === 'mixed') { + return []; + } + + $types = [$type->getName()]; + + if ($type->allowsNull() && $type->getName() !== 'null') { + $types[] = 'null'; + } + + return $types; + } + + if ($type instanceof ReflectionUnionType) { + return array_map( + static fn(ReflectionNamedType $namedType): string => $namedType->getName(), + $type->getTypes(), + ); + } + + return []; + } + + /** + * Extract non-null return type names from the transforming filter's callable. + * + * @return string[] + * + * @throws InvalidFilterException when return type is missing or void + * @throws ReflectionException + */ + public static function getReturnTypeNames( + TransformingFilterInterface $filter, + PropertyInterface $property, + ): array { + $returnType = self::reflectReturnType($filter); + + if ($returnType === null) { + throw new InvalidFilterException( + sprintf( + 'Transforming filter %s must declare a return type for property %s in file %s', + $filter->getToken(), + $property->getName(), + $property->getJsonSchema()->getFile(), + ), + ); + } + + if ($returnType instanceof ReflectionNamedType) { + $name = $returnType->getName(); + + if ($name === 'void' || $name === 'never') { + throw new InvalidFilterException( + sprintf( + 'Transforming filter %s must not declare a %s return type' + . ' for property %s in file %s', + $filter->getToken(), + $name, + $property->getName(), + $property->getJsonSchema()->getFile(), + ), + ); + } + + if ($name === 'null' || $name === 'mixed') { + return []; + } + + return [$name]; + } + + if ($returnType instanceof ReflectionUnionType) { + return array_values(array_filter( + array_map( + static fn(ReflectionNamedType $namedType): string => $namedType->getName(), + $returnType->getTypes(), + ), + static fn(string $name): bool => $name !== 'null', + )); + } + + return []; + } + + /** + * Whether the transforming filter's return type is nullable. + * + * @throws ReflectionException + */ + public static function isReturnNullable(TransformingFilterInterface $filter): bool + { + $returnType = self::reflectReturnType($filter); + + if ($returnType === null) { + return false; + } + + if ($returnType instanceof ReflectionNamedType) { + $name = $returnType->getName(); + // 'mixed' covers all types including null, but is treated as unconstrained + // (not as a nullable specific type), so we report it as non-nullable here. + if ($name === 'null' || $name === 'mixed') { + return false; + } + + return $returnType->allowsNull(); + } + + if ($returnType instanceof ReflectionUnionType) { + foreach ($returnType->getTypes() as $namedType) { + if ($namedType->getName() === 'null') { + return true; + } + } + } + + return false; + } + + /** + * @throws ReflectionException + */ + private static function reflectReturnType(TransformingFilterInterface $filter): ?\ReflectionType + { + return (new ReflectionMethod($filter->getFilter()[0], $filter->getFilter()[1]))->getReturnType(); + } +} diff --git a/src/Utils/RenderHelper.php b/src/Utils/RenderHelper.php index ea7f7e51..69e43b82 100644 --- a/src/Utils/RenderHelper.php +++ b/src/Utils/RenderHelper.php @@ -67,7 +67,7 @@ public function validationError(PropertyValidatorInterface $validator): string ); if ($this->generatorConfiguration->collectErrors()) { - return "\$this->_errorRegistry->addError($exceptionConstructor);"; + return "\$this->errorRegistry->addError($exceptionConstructor);"; } return "throw $exceptionConstructor;"; diff --git a/src/Utils/TypeCheck.php b/src/Utils/TypeCheck.php new file mode 100644 index 00000000..88a49a94 --- /dev/null +++ b/src/Utils/TypeCheck.php @@ -0,0 +1,72 @@ + 'null', ][$type] ?? $type; } + + public static function jsonSchemaToPHP(string $type): string + { + return [ + 'integer' => 'int', + 'number' => 'float', + 'boolean' => 'bool', + ][$type] ?? $type; + } } diff --git a/tests/AbstractPHPModelGeneratorTestCase.php b/tests/AbstractPHPModelGeneratorTestCase.php index 77b94114..ebb37c3a 100644 --- a/tests/AbstractPHPModelGeneratorTestCase.php +++ b/tests/AbstractPHPModelGeneratorTestCase.php @@ -6,6 +6,8 @@ use Exception; use FilesystemIterator; +use PHPModelGenerator\Attributes\JsonPointer; +use PHPModelGenerator\Interfaces\JSONModelInterface; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\SchemaProvider\OpenAPIv3Provider; use PHPModelGenerator\SchemaProvider\RecursiveDirectoryProvider; @@ -23,6 +25,7 @@ use RecursiveIteratorIterator; use ReflectionClass; use ReflectionNamedType; +use ReflectionProperty; use ReflectionType; use ReflectionUnionType; @@ -365,6 +368,27 @@ protected function assertErrorRegistryContainsException( $this->fail("Error exception $expectedException not found in error registry exception"); } + protected function assertClassHasJsonPointer(JSONModelInterface $object, string $expectedPointer): void + { + $this->assertJsonPointer(new ReflectionClass($object), $expectedPointer); + } + + protected function assertPropertyHasJsonPointer( + JSONModelInterface $object, + string $property, + string $expectedPointer, + ): void { + $this->assertJsonPointer(new ReflectionClass($object)->getProperty($property), $expectedPointer); + } + + private function assertJsonPointer(ReflectionClass | ReflectionProperty $target, string $expectedPointer): void + { + $attributes = $target->getAttributes(JsonPointer::class); + $this->assertCount(1, $attributes); + $this->assertCount(1, $attributes[0]->getArguments()); + $this->assertSame($expectedPointer, $attributes[0]->getArguments()[0]); + } + public static function validationMethodDataProvider(): array { return [ diff --git a/tests/Basic/AdditionalPropertiesTest.php b/tests/Basic/AdditionalPropertiesTest.php index da6ff6d9..84f6ff7f 100644 --- a/tests/Basic/AdditionalPropertiesTest.php +++ b/tests/Basic/AdditionalPropertiesTest.php @@ -5,7 +5,10 @@ namespace PHPModelGenerator\Tests\Basic; use PHPModelGenerator\Exception\Object\AdditionalPropertiesException; +use PHPModelGenerator\Interfaces\JSONModelInterface; use PHPModelGenerator\Model\GeneratorConfiguration; +use PHPModelGenerator\ModelGenerator; +use PHPModelGenerator\SchemaProcessor\PostProcessor\AdditionalPropertiesAccessorPostProcessor; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use stdClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -42,7 +45,7 @@ public function testAdditionalPropertiesAreIgnoredWhenSetToTrue(array $propertyV $this->assertSame($propertyValue['age'] ?? null, $object->getAge()); } - public static function additionalPropertiesDataProvider():array + public static function additionalPropertiesDataProvider(): array { return [ 'all properties plus additional property' => [['name' => 'test', 'age' => 24, 'additional' => 'ignored']], @@ -61,7 +64,7 @@ public function testDefinedPropertiesAreAcceptedWhenSetToFalse(array $propertyVa $this->assertSame($propertyValue['age'] ?? null, $object->getAge()); } - public static function definedPropertiesDataProvider():array + public static function definedPropertiesDataProvider(): array { return [ 'all properties' => [['name' => 'test', 'age' => 24]], @@ -196,6 +199,10 @@ public function testValidAdditionalPropertiesObjectsAreValid( GeneratorConfiguration $generatorConfiguration, array $propertyValue, ): void { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new AdditionalPropertiesAccessorPostProcessor()); + }; + $className = $this->generateClassFromFile('AdditionalPropertiesObject.json', $generatorConfiguration); $object = new $className($propertyValue); @@ -204,6 +211,15 @@ public function testValidAdditionalPropertiesObjectsAreValid( foreach ($propertyValue as $key => $value) { $this->assertSame($value, $object->getRawModelDataInput()[$key]); } + + // Verify JSON pointer for additional property object instances when present + $additionalInstance = $object->getAdditionalProperty('additional1') + ?? $object->getAdditionalProperty('additional2') + ?? null; + if ($additionalInstance instanceof JSONModelInterface) { + $this->assertClassHasJsonPointer($additionalInstance, '/additionalProperties'); + $this->assertPropertyHasJsonPointer($additionalInstance, 'name', '/additionalProperties/properties/name'); + } } public static function validAdditionalPropertiesObjectsDataProvider(): array diff --git a/tests/Basic/FilterTest.php b/tests/Basic/FilterTest.php index 3e5fd5b6..174d73f4 100644 --- a/tests/Basic/FilterTest.php +++ b/tests/Basic/FilterTest.php @@ -6,6 +6,8 @@ use DateTime; use Exception; +use ReflectionClass; +use RuntimeException; use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\InvalidFilterException; use PHPModelGenerator\Exception\SchemaException; @@ -59,16 +61,6 @@ public static function invalidCustomFilterDataProvider(): array ]; } - public function testFilterWithNotAllowedAcceptedTypeThrowsAnException(): void - { - $this->expectException(InvalidFilterException::class); - $this->expectExceptionMessage('Filter accepts invalid types'); - - (new GeneratorConfiguration())->addFilter( - $this->getCustomFilter([self::class, 'uppercaseFilter'], 'customFilter', ['NotExistingType']), - ); - } - public function testNonExistingFilterThrowsAnException(): void { $this->expectException(SchemaException::class); @@ -80,20 +72,13 @@ public function testNonExistingFilterThrowsAnException(): void protected function getCustomFilter( array $customFilter, string $token = 'customFilter', - array $acceptedTypes = ['string', 'null'], ): FilterInterface { - return new class ($customFilter, $token, $acceptedTypes) implements FilterInterface { + return new class ($customFilter, $token) implements FilterInterface { public function __construct( private readonly array $customFilter, private readonly string $token, - private readonly array $acceptedTypes, ) {} - public function getAcceptedTypes(): array - { - return $this->acceptedTypes; - } - public function getToken(): string { return $this->token; @@ -309,11 +294,6 @@ public static function validEncodingsDataProvider(): array private function getEncodeFilter(): FilterInterface { return new class () implements FilterInterface, ValidateOptionsInterface { - public function getAcceptedTypes(): array - { - return ['string']; - } - public function getToken(): string { return 'encode'; @@ -384,24 +364,15 @@ protected function getCustomTransformingFilter( array $customSerializer, array $customFilter = [], string $token = 'customTransformingFilter', - array $acceptedTypes = ['string'], ): TransformingFilterInterface { - return new class ($customSerializer, $customFilter, $token, $acceptedTypes) - extends TrimFilter - implements TransformingFilterInterface + return new class ($customSerializer, $customFilter, $token) extends TrimFilter implements TransformingFilterInterface { public function __construct( private readonly array $customSerializer, private readonly array $customFilter, private readonly string $token, - private readonly array $acceptedTypes, ) {} - public function getAcceptedTypes(): array - { - return $this->acceptedTypes; - } - public function getToken(): string { return $this->token; @@ -411,6 +382,7 @@ public function getFilter(): array { return empty($this->customFilter) ? parent::getFilter() : $this->customFilter; } + public function getSerializer(): array { return $this->customSerializer; @@ -484,8 +456,7 @@ public function testFilterExceptionsAreCaught(): void $this->expectExceptionMessage(<<generateClassFromFile( 'TransformingFilter.json', @@ -538,7 +509,6 @@ public function testTransformingFilterAppliedToAnArrayPropertyThrowsAnException( [self::class, 'serializeBinaryToInt'], [self::class, 'filterIntToBinary'], 'customArrayTransformer', - ['array'], ) ), ); @@ -556,11 +526,6 @@ public function testMultipleTransformingFiltersAppliedToOnePropertyThrowsAnExcep ['["dateTime", "customTransformer"]'], (new GeneratorConfiguration())->addFilter( new class () extends DateTimeFilter { - public function getAcceptedTypes(): array - { - return [DateTime::class, 'null']; - } - public function getToken(): string { return 'customTransformer'; @@ -629,7 +594,6 @@ public function testTransformingToScalarType(bool $implicitNull, string $namespa [self::class, 'serializeBinaryToInt'], [self::class, 'filterIntToBinary'], 'binary', - ['integer'], ) ), false, @@ -646,14 +610,11 @@ public function testTransformingToScalarType(bool $implicitNull, string $namespa $this->assertSame(['value' => 11], $object->toArray()); $this->assertSame('{"value":11}', $object->toJSON()); - $this->expectException(ErrorRegistryException::class); - $this->expectExceptionMessage( - $implicitNull - ? 'Filter binary is not compatible with property type NULL for property value' - : 'Invalid type for value. Requires [string, int], got NULL', - ); - - new $fqcn(['value' => null]); + if (!$implicitNull) { + $this->expectException(ErrorRegistryException::class); + $this->expectExceptionMessage('Invalid type for value. Requires [string, int], got NULL'); + new $fqcn(['value' => null]); + } } public static function filterIntToBinary(int $value): string @@ -688,7 +649,6 @@ public function testFilterChainWithTransformingFilter(): void $this->getCustomFilter( [self::class, 'stripTimeFilter'], 'stripTime', - [DateTime::class, 'null'], ) ), false, @@ -721,7 +681,6 @@ public function testFilterChainWithTransformingFilterOnMultiTypeProperty( $this->getCustomFilter( [self::class, 'stripTimeFilter'], 'stripTime', - [DateTime::class, 'null'], ) ), false, @@ -766,7 +725,6 @@ public function testFilterChainWithIncompatibleFilterAfterTransformingFilterOnMu $this->getCustomFilter( [self::class, 'stripTimeFilterStrict'], 'stripTime', - [DateTime::class], ) ), ); @@ -784,9 +742,8 @@ public function testFilterAfterTransformingFilterIsSkippedIfTransformingFilterFa (new GeneratorConfiguration()) ->addFilter( $this->getCustomFilter( - [self::class, 'exceptionFilter'], + [self::class, 'exceptionFilterDateTime'], 'stripTime', - [DateTime::class, 'null'], ) ), false, @@ -795,32 +752,29 @@ public function testFilterAfterTransformingFilterIsSkippedIfTransformingFilterFa new $className(['filteredProperty' => 'Hello']); } - public function testFilterWhichAppliesToMultiTypePropertyPartiallyThrowsAnException(): void + public function testFilterWhichAppliesToMultiTypePropertyPartiallyIsAllowed(): void { - $this->expectException(SchemaException::class); - $this->expectExceptionMessage( - 'Filter trim is not compatible with property type null for property filteredProperty', - ); - - $this->generateClassFromFile( + // A filter with acceptedTypes = ['string'] applied to a string|null property has partial + // overlap and is valid — the runtime typeCheck skips the filter for null values. + $className = $this->generateClassFromFile( 'FilterChainMultiType.json', (new GeneratorConfiguration()) ->addFilter( $this->getCustomFilter( - [self::class, 'stripTimeFilter'], + [self::class, 'uppercaseFilterStringOnly'], 'trim', - ['string'], ) ) ->addFilter( $this->getCustomFilter( [self::class, 'stripTimeFilter'], 'stripTime', - [DateTime::class, 'null'], ) ), false, ); + + $this->assertNotNull($className); } public static function stripTimeFilter(?DateTime $value): ?DateTime @@ -894,4 +848,744 @@ public function testDefaultValuesAreTransformed(bool $implicitNull): void $object->getCreated()->format(DATE_ATOM), ); } + + // --- Filter callables used in the tests below --- + + public static function uppercaseFilterAllTypes(mixed $value): ?string + { + return is_string($value) ? strtoupper($value) : null; + } + + public static function uppercaseFilterStringOnly(string $value): string + { + return strtoupper($value); + } + + public static function uppercaseFilterFloat(float $value): string + { + return (string) $value; + } + + public static function uppercaseFilterMixed(mixed $value): ?string + { + return is_string($value) ? strtoupper($value) : null; + } + + public static function nullPassthrough(null $value): mixed + { + return $value; + } + + public static function exceptionFilterDateTime(?\DateTime $value): void + { + throw new Exception("Exception filter called with DateTime"); + } + + public static function negateFilterMixed(mixed $value): mixed + { + return is_int($value) ? -$value : $value; + } + + // --- Tests --- + + public function testFilterWithMixedTypeHintIsCompatibleWithAnyPropertyType(): void + { + // A callable with 'mixed' type hint derives empty acceptedTypes — no runtime type guard, + // the filter runs for all value types. + $className = $this->generateClassFromFile( + 'StringPropertyAcceptAllFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterAllTypes'], 'acceptAll'), + ), + ); + + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); + } + + public function testRestrictedFilterOnUntypedPropertyIsAllowed(): void + { + // 'trim' accepts string|null (from ?string type hint). An untyped property can hold any + // value, so the filter is applied only when the runtime type matches — generation must + // succeed without throwing a SchemaException. + $className = $this->generateClassFromFile('UntypedPropertyFilter.json'); + + $object = new $className(['property' => ' hello ']); + $this->assertSame('hello', $object->getProperty()); + + $object = new $className(['property' => null]); + $this->assertNull($object->getProperty()); + } + + public function testZeroOverlapThrowsSchemaException(): void + { + // float has zero overlap with int — SchemaException at generation time. + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + '/Filter numberFilter is not compatible with property type int for property property/', + ); + + $this->generateClassFromFile( + 'IntegerPropertyZeroOverlapFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterFloat'], 'numberFilter'), + ), + ); + } + + // --- P2: string|integer property with string-only filter --- + + public function testPartialOverlapStringFilterOnMultiTypeProperty(): void + { + // Filter callable has (string $value) — accepted type is string only. + // Filter applies for string values; integer is not accepted so the filter + // is skipped and the integer value passes through unchanged. + $className = $this->generateClassFromFile( + 'StringIntegerPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterStringOnly'], 'customFilter'), + ), + ); + + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); // filter applied + + $object = new $className(['property' => 5]); + $this->assertSame(5, $object->getProperty()); // filter skipped, value unchanged + } + + // --- P3: string|null property, filter does not cover null --- + + public function testPartialOverlapStringFilterSkipsNullOnNullableProperty(): void + { + // Filter callable has (string $value) — null is not accepted. + // Filter applies for string values; null passes through unchanged. + $className = $this->generateClassFromFile( + 'StringNullPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterStringOnly'], 'customFilter'), + ), + ); + + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); // filter applied + + $object = new $className(['property' => null]); + $this->assertNull($object->getProperty()); // filter skipped, null unchanged + } + + // --- P4: string|null property, filter covers only null --- + + public function testPartialOverlapNullFilterSkipsStringOnNullableProperty(): void + { + // Filter callable has (null $value) — only null is accepted. + // Filter runs for null (passes through); string is not accepted so skipped. + $className = $this->generateClassFromFile( + 'StringNullPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'nullPassthrough'], 'customFilter'), + ), + ); + + $object = new $className(['property' => null]); + $this->assertNull($object->getProperty()); // filter ran, returned null + + $object = new $className(['property' => 'hello']); + $this->assertSame('hello', $object->getProperty()); // filter skipped, string unchanged + } + + // --- P5: integer property, filter covers integer --- + + public function testPartialOverlapFilterRunsWhenPropertyTypeIsInAcceptedTypes(): void + { + // Filter callable has (int $value) — overlap with integer property type, filter runs. + $className = $this->generateClassFromFile( + 'IntegerPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'negateFilter'], 'customFilter'), + ), + ); + + $object = new $className(['property' => 5]); + $this->assertSame(-5, $object->getProperty()); + } + + public static function negateFilter(int $value): int + { + return -$value; + } + + // --- U3: mixed-typed callable on untyped property, no typeCheck generated --- + + public function testMixedTypedCallableFilterOnUntypedProperty(): void + { + // callable with (mixed $value) derives empty acceptedTypes — no runtime typeCheck. + $className = $this->generateClassFromFile( + 'UntypedPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterMixed'], 'customFilter'), + ), + ); + + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); // filter applied for string + } + + // --- U4: narrow filter on untyped property --- + + public function testNarrowFilterOnUntypedPropertySkipsNonMatchingType(): void + { + // Callable with (string $value) on an untyped property — filter applies for string, + // integer is not accepted so the filter is skipped and the value passes through. + $className = $this->generateClassFromFile( + 'UntypedPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterStringOnly'], 'customFilter'), + ), + ); + + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); // filter applied for string + + $object = new $className(['property' => 5]); + $this->assertSame(5, $object->getProperty()); // filter skipped, integer unchanged + } + + public function testAddFilterWithMixedTypedCallableIsAllowed(): void + { + // A callable with (mixed $value) derives empty acceptedTypes — always compatible. + $config = (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterMixed'], 'mixedFilter'), + ); + + $this->assertNotNull($config->getFilter('mixedFilter')); + } + + public function testMixedTypedCallableGeneratesNoRuntimeTypeCheck(): void + { + // A callable with (mixed $value) means "accept all types" — generation succeeds for both + // typed and untyped properties, and no runtime typeCheck guard is emitted. + + // typed string property — filter runs + $className = $this->generateClassFromFile( + 'StringPropertyMixedFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterMixed'], 'mixedFilter'), + ), + ); + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); + + // untyped property — filter runs + $className = $this->generateClassFromFile( + 'UntypedPropertyMixedFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterMixed'], 'mixedFilter'), + ), + ); + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); + + // typed integer property — generation succeeds, filter runs (no typeCheck guard) + $className = $this->generateClassFromFile( + 'IntegerPropertyMixedFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'negateFilterMixed'], 'mixedFilter'), + ), + ); + $object = new $className(['property' => 5]); + $this->assertSame(-5, $object->getProperty()); + } + + // --- Static callables for Phase 4d tests --- + + /** + * Accepts string or int, converts to string. Used for the union-type-hint guard test. + */ + public static function intOrStringFilter(string|int $value): string + { + return (string) $value; + } + + /** + * Accepts string or null, always returns string (never null). Used for null-consumed test. + */ + public static function stringOrNullToStringFilter(string|null $value): string + { + return (string) $value; + } + + /** + * Accepts string, returns int|string union. Used for union-return-type test. + */ + public static function stringToIntOrStringFilter(string $value): int|string + { + return is_numeric($value) ? (int) $value : $value; + } + + /** + * Serializer for stringToIntOrStringFilter. + */ + public static function intOrStringSerializer(int|string $value): string + { + return (string) $value; + } + + /** + * No type hint on first parameter. Used for the no-type-hint InvalidFilterException test. + * + * @param mixed $value + */ + public static function untypedFilter($value): string + { + return (string) $value; + } + + /** + * No return type hint. Used for the missing-return-type InvalidFilterException test (F5). + * + * @return string + */ + public static function filterWithNoReturnType(string $value) + { + return $value; + } + + /** + * Void return type. Used for the void-return-type InvalidFilterException test (F6). + */ + public static function filterWithVoidReturnType(string $value): void + { + } + + /** + * Never return type. Used for the never-return-type InvalidFilterException test (F7). + */ + public static function filterWithNeverReturnType(string $value): never + { + throw new RuntimeException('never'); + } + + // --- Phase 4d: output type formula, reflection, filter chain tests --- + + /** + * R2: TransformingFilter (int→string via binary) on a string|integer property. + * The filter callable accepts only int, so string values bypass the filter unchanged. + * Verifies the bypass formula: bypass_names = base_names − accepted_non_null. + */ + public function testTransformingFilterWithBypassOnMultiTypeProperty(): void + { + // base type = string|int; filter accepts int only → string bypasses, int is transformed. + $className = $this->generateClassFromFile( + 'StringIntegerPropertyBinaryFilter.json', + (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'serializeBinaryToInt'], + [self::class, 'filterIntToBinary'], + 'binary', + ), + ), + ); + + // int input: filter applies (decbin), returns binary string + $object = new $className(['property' => 9]); + $this->assertSame('1001', $object->getProperty()); + + // string input: filter is skipped (string bypasses), value passes through unchanged + $object = new $className(['property' => 'hello']); + $this->assertSame('hello', $object->getProperty()); + + // setter: int is re-transformed + $object->setProperty(5); + $this->assertSame('101', $object->getProperty()); + + // setter: string is preserved (bypass) + $object->setProperty('world'); + $this->assertSame('world', $object->getProperty()); + } + + /** + * R6: TransformingFilter with a union return type (int|string) on a string property. + * The output type is widened to int|string; the setter must accept both int and string. + */ + public function testTransformingFilterWithUnionReturnType(): void + { + $className = $this->generateClassFromFile( + 'StringPropertyIntOrStringFilter.json', + (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'intOrStringSerializer'], + [self::class, 'stringToIntOrStringFilter'], + 'intOrString', + ), + ), + ); + + // numeric string → filter converts to int + $object = new $className(['property' => '42']); + $this->assertSame(42, $object->getProperty()); + + // non-numeric string → filter returns as-is (string) + $object = new $className(['property' => 'hello']); + $this->assertSame('hello', $object->getProperty()); + + // setter accepts int (pass-through: already a transformed output type) + $object->setProperty(7); + $this->assertSame(7, $object->getProperty()); + + // setter accepts string (base type or output type string) + $object->setProperty('abc'); + $this->assertSame('abc', $object->getProperty()); + } + + /** + * R7: TransformingFilter where both string and null are in its accepted types. + * Null is NOT a bypass type — the filter runs for null and converts it to string. + */ + public function testTransformingFilterNullConsumedByFilter(): void + { + $className = $this->generateClassFromFile( + 'StringNullPropertyStrOrNullFilter.json', + (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'intOrStringSerializer'], + [self::class, 'stringOrNullToStringFilter'], + 'strOrNull', + ), + ), + ); + + // string input: filter runs and returns string + $object = new $className(['property' => 'hello']); + $this->assertSame('hello', $object->getProperty()); + + // null input: filter runs (null IS accepted) and converts null → '' + $object = new $className(['property' => null]); + $this->assertSame('', $object->getProperty()); + } + + /** + * F3: Filter callable whose first parameter has no type hint throws an InvalidFilterException + * at class-generation time (reflection cannot derive the accepted types). + * This is not a SchemaException because the error is in the filter definition, not the schema. + */ + public function testFilterCallableWithNoTypeHintThrowsInvalidFilterException(): void + { + $this->expectException(InvalidFilterException::class); + $this->expectExceptionMessageMatches('/Filter noTypeHint must declare a type hint/'); + + $this->generateClassFromFile( + 'StringPropertyNoTypeHintFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'untypedFilter'], 'noTypeHint'), + ), + ); + } + + /** + * F5: Transforming filter callable with no return type hint throws an InvalidFilterException + * at class-generation time (reflection cannot derive the output type). + * This is not a SchemaException because the error is in the filter definition, not the schema. + */ + public function testTransformingFilterWithMissingReturnTypeThrowsInvalidFilterException(): void + { + $this->expectException(InvalidFilterException::class); + $this->expectExceptionMessageMatches('/Transforming filter noReturnType must declare a return type/'); + + $this->generateClassFromFile( + 'StringPropertyNoReturnTypeFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'intOrStringSerializer'], + [self::class, 'filterWithNoReturnType'], + 'noReturnType', + ), + ), + ); + } + + /** + * F6: Transforming filter callable with a void return type throws an InvalidFilterException + * at class-generation time (void is not a valid output type for a transforming filter). + * This is not a SchemaException because the error is in the filter definition, not the schema. + */ + public function testTransformingFilterWithVoidReturnTypeThrowsInvalidFilterException(): void + { + $this->expectException(InvalidFilterException::class); + $this->expectExceptionMessageMatches('/Transforming filter voidReturn must not declare a void return type/'); + + $this->generateClassFromFile( + 'StringPropertyVoidReturnFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'intOrStringSerializer'], + [self::class, 'filterWithVoidReturnType'], + 'voidReturn', + ), + ), + ); + } + + /** + * F7: Transforming filter callable with a never return type throws an InvalidFilterException + * at class-generation time (never, like void, cannot produce a usable return value). + * This is not a SchemaException because the error is in the filter definition, not the schema. + */ + public function testTransformingFilterWithNeverReturnTypeThrowsInvalidFilterException(): void + { + $this->expectException(InvalidFilterException::class); + $this->expectExceptionMessageMatches('/Transforming filter neverReturn must not declare a never return type/'); + + $this->generateClassFromFile( + 'StringPropertyNeverReturnFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'intOrStringSerializer'], + [self::class, 'filterWithNeverReturnType'], + 'neverReturn', + ), + ), + ); + } + + /** + * F4: Filter callable with a union type hint (string|int) generates a compound typeCheck + * guard: (is_string($value) || is_int($value)). The filter runs for both accepted types. + */ + public function testFilterCallableWithUnionTypeHintAppliesFilterForBothAcceptedTypes(): void + { + // Both string and int are in the callable's union type hint — both pass the runtime guard. + $className = $this->generateClassFromFile( + 'StringIntegerPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'intOrStringFilter'], 'customFilter'), + ), + ); + + // string input: is_string passes → filter runs → result is string (unchanged) + $object = new $className(['property' => 'hello']); + $this->assertSame('hello', $object->getProperty()); + + // int input: is_int passes → filter runs → result is string '42' + $object = new $className(['property' => 42]); + $this->assertSame('42', $object->getProperty()); + } + + /** + * CH2: [trim, dateTime] filter chain on a string|integer property. + * trim accepts only string|null — the int input bypasses trim. + * dateTime accepts string|int|float|null — both inputs are converted to DateTime. + */ + public function testFilterChainTrimDateTimeOnStringIntegerProperty(): void + { + $className = $this->generateClassFromFile( + 'StringIntegerPropertyFilterChain.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + // string input: trim trims whitespace, dateTime converts to DateTime + $object = new $className(['created' => ' 2020-12-12 ']); + $this->assertInstanceOf(\DateTime::class, $object->getCreated()); + $this->assertSame( + (new \DateTime('2020-12-12'))->format(DATE_ATOM), + $object->getCreated()->format(DATE_ATOM), + ); + + // int input: trim is skipped (not a string), dateTime converts timestamp to DateTime + $object = new $className(['created' => 0]); + $this->assertInstanceOf(\DateTime::class, $object->getCreated()); + $this->assertSame( + (new \DateTime('@0'))->format(DATE_ATOM), + $object->getCreated()->format(DATE_ATOM), + ); + + // setter accepts DateTime (already-transformed output type) + $object->setCreated(new \DateTime('2020-12-12')); + $this->assertSame( + (new \DateTime('2020-12-12'))->format(DATE_ATOM), + $object->getCreated()->format(DATE_ATOM), + ); + } + + public function testFilterChainWithTransformingFilterOnUntypedProperty(): void + { + // ['trim', 'dateTime'] on an untyped property — trim accepts string|null (from ?string + // type hint) but the property is untyped, so no SchemaException is thrown and the + // chain works correctly. + $className = $this->generateClassFromFile('UntypedPropertyFilterChain.json'); + + $object = new $className(['filteredProperty' => ' 2020-12-12 ']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + $this->assertSame( + (new DateTime('2020-12-12'))->format(DATE_ATOM), + $object->getFilteredProperty()->format(DATE_ATOM), + ); + + $object = new $className(['filteredProperty' => null]); + $this->assertNull($object->getFilteredProperty()); + } + + /** + * FC-M1: A transforming filter with a mixed return type followed by a filter that does NOT + * accept all types must throw a SchemaException. + * + * Covers FilterValidator::validateFilterCompatibilityWithTransformedType lines 187–198 + * (throw when the transforming filter's return type is mixed/unconstrained but the next + * filter has non-empty accepted types). + */ + public function testMixedReturnTransformingFilterFollowedByTypedFilterThrowsException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessage( + 'Filter trim is not compatible with the unconstrained output of' + . ' transforming filter mixedReturnFilter for property filteredProperty', + ); + + $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['["mixedReturnFilter", "trim"]'], + (new GeneratorConfiguration())->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'serializeMixedReturn'], + [self::class, 'filterWithMixedReturn'], + 'mixedReturnFilter', + ), + ), + false, + ); + } + + /** + * FC-M2: A transforming filter with a mixed return type followed by a filter that accepts + * all types (mixed first parameter) must not throw. + * FC-M3: A transforming filter with a concrete return type followed by an accept-all filter + * must not throw. + * + * FC-M2 covers FilterValidator line 201 (return after the unconstrained-output block when + * the next filter accepts all types) and TransformingFilterOutputTypePostProcessor line 95 + * (early return when the transforming filter itself has a mixed/unconstrained return type). + * FC-M3 covers FilterValidator line 206 (return when the next filter's accepted types are + * empty, i.e. it accepts all types). + */ + public function testFilterChainWithAcceptAllNextFilter(): void + { + $acceptAllFilter = $this->getCustomFilter([self::class, 'acceptAllFilter'], 'acceptAll'); + + // FC-M2: mixed-return transforming filter + accept-all follow-up — no SchemaException. + // Lines 201 (FilterValidator) and 95 (TransformingFilterOutputTypePostProcessor) covered. + $mixedReturnClassName = $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['["mixedReturnFilter", "acceptAll"]'], + (new GeneratorConfiguration()) + ->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'serializeMixedReturn'], + [self::class, 'filterWithMixedReturn'], + 'mixedReturnFilter', + ), + ) + ->addFilter($acceptAllFilter), + false, + ); + + // The mixed-return filter just passes the string through; value is still a string. + $object = new $mixedReturnClassName(['filteredProperty' => 'hello']); + $this->assertSame('hello', $object->getFilteredProperty()); + + // FC-M3: concrete-return transforming filter (dateTime → DateTime) + accept-all follow-up + // — no SchemaException. Line 206 (FilterValidator) covered. + $dateTimeClassName = $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['["dateTime", "acceptAll"]'], + (new GeneratorConfiguration())->addFilter($acceptAllFilter), + false, + ); + + // The dateTime filter converts the string to DateTime; acceptAll passes it through. + $object = new $dateTimeClassName(['filteredProperty' => '2020-12-12']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + } + + /** + * FC-I1: A transforming filter with a non-nullable return type followed by a filter that + * does not accept that return type must throw a SchemaException. + * + * Covers FilterValidator::validateFilterCompatibilityWithTransformedType lines 212, 218, 221 + * (false branches of $returnNullable ternaries and single-type display path). + */ + public function testNonNullableReturnTransformingFilterWithIncompatibleNextFilterThrowsException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessage( + 'Filter trim is not compatible with transformed property type int' + . ' for property filteredProperty', + ); + + $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['["intReturnFilter", "trim"]'], + (new GeneratorConfiguration())->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'serializeIntReturn'], + [self::class, 'filterWithIntReturn'], + 'intReturnFilter', + ), + ), + false, + ); + } + + // --- Callables for mixed-return / accept-all / int-return / mixed-accept filter tests --- + + /** + * Transforming filter callable that returns mixed. + * Used for FC-M1 and FC-M2. + */ + public static function filterWithMixedReturn(string $value): mixed + { + return $value; + } + + /** + * Serializer for filterWithMixedReturn. + */ + public static function serializeMixedReturn(mixed $value): string + { + return (string) $value; + } + + /** + * Regular filter callable that accepts and returns mixed (accept-all filter). + * Used for FC-M2 and FC-M3. + */ + public static function acceptAllFilter(mixed $value): mixed + { + return $value; + } + + /** + * Transforming filter callable that returns a non-nullable int. + * Used for FC-I1. + */ + public static function filterWithIntReturn(string $value): int + { + return (int) $value; + } + + /** + * Serializer for filterWithIntReturn. + */ + public static function serializeIntReturn(int $value): string + { + return (string) $value; + } } diff --git a/tests/Basic/PatternPropertiesTest.php b/tests/Basic/PatternPropertiesTest.php index 43f299c6..298e3caf 100644 --- a/tests/Basic/PatternPropertiesTest.php +++ b/tests/Basic/PatternPropertiesTest.php @@ -199,4 +199,31 @@ public function testPatternWithTransformingFilterSkipsIntersectionCheck(): void // updates the declared property type to DateTime (the filter's output type). $this->assertEqualsCanonicalizing(['DateTime', 'null'], $this->getReturnTypeNames($className, 'getAlpha')); } + + public function testObjectTypedPatternPropertiesAreValidated(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new PatternPropertiesAccessorPostProcessor()); + }; + + $className = $this->generateClassFromFile('PatternPropertiesObjectType.json'); + + $object = new $className([ + 'person_alice' => ['name' => 'Alice', 'age' => 30], + 'person_bob' => ['name' => 'Bob'], + ]); + + $instances = $object->getPatternProperties('^person_'); + $this->assertCount(2, $instances); + + $alice = $instances['person_alice']; + $this->assertSame('Alice', $alice->getName()); + $this->assertSame(30, $alice->getAge()); + $this->assertClassHasJsonPointer($alice, '/patternProperties/^person_'); + $this->assertPropertyHasJsonPointer($alice, 'name', '/patternProperties/^person_/properties/name'); + + $bob = $instances['person_bob']; + $this->assertSame('Bob', $bob->getName()); + $this->assertNull($bob->getAge()); + } } diff --git a/tests/Basic/PhpAttributeTest.php b/tests/Basic/PhpAttributeTest.php index 118d3e9e..7041cc1f 100644 --- a/tests/Basic/PhpAttributeTest.php +++ b/tests/Basic/PhpAttributeTest.php @@ -4,13 +4,18 @@ namespace PHPModelGenerator\Tests\Basic; +use PHPModelGenerator\Attributes\Deprecated; +use PHPModelGenerator\Attributes\JsonPointer; +use PHPModelGenerator\Attributes\JsonSchema; +use PHPModelGenerator\Attributes\ReadOnlyProperty; +use PHPModelGenerator\Attributes\Required; +use PHPModelGenerator\Attributes\SchemaName; +use PHPModelGenerator\Attributes\Source; +use PHPModelGenerator\Attributes\WriteOnlyProperty; +use PHPModelGenerator\Model\Attributes\PhpAttribute; use PHPModelGenerator\Model\GeneratorConfiguration; -use PHPModelGenerator\Model\PhpAttribute; -use PHPModelGenerator\Model\Property\PropertyInterface; -use PHPModelGenerator\Model\Schema; -use PHPModelGenerator\ModelGenerator; -use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; +use ReflectionClass; /** * Class PhpAttributeTest @@ -19,293 +24,101 @@ */ class PhpAttributeTest extends AbstractPHPModelGeneratorTestCase { - // Fake FQCNs used only for import and rendering tests; no instantiation. - private const FQCN_CLASS_ATTR = 'Some\\External\\ClassAttr'; - private const FQCN_PROPERTY_ATTR = 'Some\\External\\PropertyAttr'; - private const FQCN_COLUMN_ATTR = 'Some\\External\\Column'; - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - private function addClassAttribute(PhpAttribute $attribute): void - { - $this->modifyModelGenerator = static function (ModelGenerator $generator) use ($attribute): void { - $generator->addPostProcessor(new class ($attribute) extends PostProcessor { - public function __construct(private readonly PhpAttribute $attribute) {} - - public function process(Schema $schema, GeneratorConfiguration $config): void - { - $schema->addAttribute($this->attribute); - } - }); - }; - } - - private function addPropertyAttribute(string $propertyName, PhpAttribute $attribute): void + public function testDefaultAttributes(): void { - $this->modifyModelGenerator = static function (ModelGenerator $generator) use ( - $propertyName, - $attribute, - ): void { - $generator->addPostProcessor(new class ($propertyName, $attribute) extends PostProcessor { - public function __construct( - private readonly string $propertyName, - private readonly PhpAttribute $attribute, - ) {} - - public function process(Schema $schema, GeneratorConfiguration $config): void - { - foreach ($schema->getProperties() as $property) { - if ($property->getName() === $this->propertyName) { - $property->addAttribute($this->attribute); - } - } - } - }); - }; - } - - private function getGeneratedSource(): string - { - return file_get_contents($this->getGeneratedFiles()[0]); - } - - // ------------------------------------------------------------------------- - // Class-level attributes - // ------------------------------------------------------------------------- - - public function testClassAttributeWithoutArguments(): void - { - $this->addClassAttribute(new PhpAttribute(self::FQCN_CLASS_ATTR)); - $this->generateClassFromFile('BasicSchema.json'); - - $source = $this->getGeneratedSource(); - - $this->assertStringContainsString('#[ClassAttr]', $source); - $this->assertStringContainsString('use Some\\External\\ClassAttr;', $source); - } - - public function testClassAttributeWithPositionalArgument(): void - { - $this->addClassAttribute(new PhpAttribute(self::FQCN_CLASS_ATTR, ["'my-value'"])); - $this->generateClassFromFile('BasicSchema.json'); - - $source = $this->getGeneratedSource(); - - $this->assertStringContainsString("#[ClassAttr('my-value')]", $source); - $this->assertStringContainsString('use Some\\External\\ClassAttr;', $source); + $object = $this->generateClassFromFile('BasicSchema.json'); + + $classAttributes = new ReflectionClass($object)->getAttributes(); + $this->assertCount(2, $classAttributes); + $this->assertSame(JsonPointer::class, $classAttributes[0]->getName()); + $this->assertSame('', $classAttributes[0]->getArguments()[0]); + $this->assertSame(Deprecated::class, $classAttributes[1]->getName()); + $this->assertEmpty($classAttributes[1]->getArguments()); + + $propertyAttributes = new ReflectionClass($object)->getProperties()[0]->getAttributes(); + $this->assertCount(4, $propertyAttributes); + $this->assertSame(JsonPointer::class, $propertyAttributes[0]->getName()); + $this->assertSame('/properties/my property', $propertyAttributes[0]->getArguments()[0]); + $this->assertSame(SchemaName::class, $propertyAttributes[1]->getName()); + $this->assertSame('my property', $propertyAttributes[1]->getArguments()[0]); + $this->assertSame(Required::class, $propertyAttributes[2]->getName()); + $this->assertEmpty($propertyAttributes[2]->getArguments()); + $this->assertSame(ReadOnlyProperty::class, $propertyAttributes[3]->getName()); + $this->assertEmpty($propertyAttributes[3]->getArguments()); + + $propertyAttributes = new ReflectionClass($object)->getProperties()[1]->getAttributes(); + // pointer, schema name, deprecated + $this->assertCount(3, $propertyAttributes); + $this->assertSame(Deprecated::class, $propertyAttributes[2]->getName()); + $this->assertEmpty($propertyAttributes[2]->getArguments()); + + $propertyAttributes = new ReflectionClass($object)->getProperties()[2]->getAttributes(); + // pointer, schema name, writeOnly + $this->assertCount(3, $propertyAttributes); + $this->assertSame(WriteOnlyProperty::class, $propertyAttributes[2]->getName()); + $this->assertEmpty($propertyAttributes[2]->getArguments()); + + $propertyAttributes = new ReflectionClass($object)->getProperties()[3]->getAttributes(); + $this->assertCount(2, $propertyAttributes); + $this->assertSame(JsonPointer::class, $propertyAttributes[0]->getName()); + $this->assertSame('/properties/123name', $propertyAttributes[0]->getArguments()[0]); + $this->assertSame(SchemaName::class, $propertyAttributes[1]->getName()); + $this->assertSame('123name', $propertyAttributes[1]->getArguments()[0]); + + // Verify JSON Pointer RFC 6901 encoding: '/' encodes to '~1', '~' encodes to '~0' + $instance = new $object(['my property' => 'Hello World']); + $this->assertPropertyHasJsonPointer($instance, 'slashProperty', '/properties/slash~1property'); + $this->assertPropertyHasJsonPointer($instance, 'tildeProperty', '/properties/tilde~0property'); } - public function testClassAttributeWithNamedArguments(): void + public function testBuiltinAttributes(): void { - $this->addClassAttribute( - new PhpAttribute(self::FQCN_CLASS_ATTR, ['path' => "'/api/resource'", 'methods' => "['GET']"]), + $configuration = (new GeneratorConfiguration()) + ->disableAttributes( + PhpAttribute::SCHEMA_NAME + | PhpAttribute::READ_WRITE_ONLY + | PhpAttribute::REQUIRED, + ) + ->enableAttributes(PhpAttribute::JSON_SCHEMA); + + $this->assertSame( + PhpAttribute::JSON_SCHEMA + | PhpAttribute::DEPRECATED + | PhpAttribute::SCHEMA_NAME + | PhpAttribute::JSON_POINTER, + $configuration->getEnabledAttributes(), ); - $this->generateClassFromFile('BasicSchema.json'); - - $source = $this->getGeneratedSource(); - - $this->assertStringContainsString("#[ClassAttr(path: '/api/resource', methods: ['GET'])]", $source); - $this->assertStringContainsString('use Some\\External\\ClassAttr;', $source); - } - - public function testMultipleClassAttributes(): void - { - $this->modifyModelGenerator = static function (ModelGenerator $generator): void { - $generator->addPostProcessor(new class () extends PostProcessor { - public function process(Schema $schema, GeneratorConfiguration $config): void - { - $schema->addAttribute(new PhpAttribute('Some\\External\\ClassAttr')); - $schema->addAttribute(new PhpAttribute('Some\\External\\PropertyAttr', ['name' => "'test'"])); - } - }); - }; - - $this->generateClassFromFile('BasicSchema.json'); - - $source = $this->getGeneratedSource(); - - $this->assertStringContainsString('#[ClassAttr]', $source); - $this->assertStringContainsString("#[PropertyAttr(name: 'test')]", $source); - $this->assertStringContainsString('use Some\\External\\ClassAttr;', $source); - $this->assertStringContainsString('use Some\\External\\PropertyAttr;', $source); - } - - // ------------------------------------------------------------------------- - // Property-level attributes - // ------------------------------------------------------------------------- - - public function testPropertyAttributeWithoutArguments(): void - { - $this->addPropertyAttribute('name', new PhpAttribute(self::FQCN_PROPERTY_ATTR)); - $this->generateClassFromFile('BasicSchema.json'); - - $source = $this->getGeneratedSource(); - - $this->assertStringContainsString('#[PropertyAttr]', $source); - $this->assertStringContainsString('use Some\\External\\PropertyAttr;', $source); - } - - public function testPropertyAttributeWithNamedArguments(): void - { - $this->addPropertyAttribute( - 'name', - new PhpAttribute(self::FQCN_COLUMN_ATTR, ['name' => "'full_name'", 'nullable' => 'false']), - ); - $this->generateClassFromFile('BasicSchema.json'); - - $source = $this->getGeneratedSource(); - - $this->assertStringContainsString("#[Column(name: 'full_name', nullable: false)]", $source); - $this->assertStringContainsString('use Some\\External\\Column;', $source); - } - - public function testMultiplePropertiesEachWithAttribute(): void - { - $this->modifyModelGenerator = static function (ModelGenerator $generator): void { - $generator->addPostProcessor(new class () extends PostProcessor { - public function process(Schema $schema, GeneratorConfiguration $config): void - { - foreach ($schema->getProperties() as $property) { - $property->addAttribute( - new PhpAttribute('Some\\External\\Column', ['name' => "'" . $property->getName() . "'"]), - ); - } - } - }); - }; - - $this->generateClassFromFile('BasicSchema.json'); - - $source = $this->getGeneratedSource(); - - $this->assertStringContainsString("#[Column(name: 'name')]", $source); - $this->assertStringContainsString("#[Column(name: 'age')]", $source); - // Only one import despite the same FQCN on multiple properties - $this->assertSame(1, substr_count($source, 'use Some\\External\\Column;')); - } - - // ------------------------------------------------------------------------- - // Combined class + property attributes - // ------------------------------------------------------------------------- - - public function testClassAndPropertyAttributesCombined(): void - { - $this->modifyModelGenerator = static function (ModelGenerator $generator): void { - $generator->addPostProcessor(new class () extends PostProcessor { - public function process(Schema $schema, GeneratorConfiguration $config): void - { - $schema->addAttribute(new PhpAttribute('Some\\External\\ClassAttr')); - - foreach ($schema->getProperties() as $property) { - if ($property->getName() === 'name') { - $property->addAttribute(new PhpAttribute('Some\\External\\Column')); - } - } - } - }); - }; - - $this->generateClassFromFile('BasicSchema.json'); - - $source = $this->getGeneratedSource(); - - $this->assertStringContainsString('#[ClassAttr]', $source); - $this->assertStringContainsString('#[Column]', $source); - $this->assertStringContainsString('use Some\\External\\ClassAttr;', $source); - $this->assertStringContainsString('use Some\\External\\Column;', $source); - } - - // ------------------------------------------------------------------------- - // Import deduplication - // ------------------------------------------------------------------------- - - public function testSameFqcnOnClassAndPropertyGeneratesOneImport(): void - { - $this->modifyModelGenerator = static function (ModelGenerator $generator): void { - $generator->addPostProcessor(new class () extends PostProcessor { - public function process(Schema $schema, GeneratorConfiguration $config): void - { - $schema->addAttribute(new PhpAttribute('Some\\External\\ClassAttr')); - - foreach ($schema->getProperties() as $property) { - $property->addAttribute(new PhpAttribute('Some\\External\\ClassAttr')); - } - } - }); - }; - - $this->generateClassFromFile('BasicSchema.json'); - - $source = $this->getGeneratedSource(); - $this->assertSame(1, substr_count($source, 'use Some\\External\\ClassAttr;')); - } - - // ------------------------------------------------------------------------- - // Namespace filtering - // ------------------------------------------------------------------------- - - public function testAttributeInSameNamespaceGeneratesNoImport(): void - { - $this->modifyModelGenerator = static function (ModelGenerator $generator): void { - $generator->addPostProcessor(new class () extends PostProcessor { - public function process(Schema $schema, GeneratorConfiguration $config): void - { - // FQCN matches the namespace prefix configured below - $schema->addAttribute(new PhpAttribute('MyApp\\Model\\SameNsAttr')); - } - }); - }; - - $this->generateClassFromFile( - 'BasicSchema.json', - (new GeneratorConfiguration()) - ->setCollectErrors(false) - ->setNamespacePrefix('MyApp\\Model'), + $configuration->setEnabledAttributes(PhpAttribute::JSON_SCHEMA | PhpAttribute::SOURCE); + + $object = $this->generateClassFromFile('BasicSchema.json', $configuration); + + $classAttributes = new ReflectionClass($object)->getAttributes(); + $this->assertCount(3, $classAttributes); + + $this->assertSame(JsonPointer::class, $classAttributes[0]->getName()); + $this->assertSame('', $classAttributes[0]->getArguments()[0]); + $this->assertSame(JsonSchema::class, $classAttributes[1]->getName()); + // json_encode escapes '/' as '\/' by default; verify the embedded schema contains all properties + $jsonSchemaArg = $classAttributes[1]->getArguments()[0]; + $this->assertStringContainsString('"type":"object"', $jsonSchemaArg); + $this->assertStringContainsString('"my property":{"type":"string"', $jsonSchemaArg); + $this->assertStringContainsString('"tilde~property":{"type":"string"', $jsonSchemaArg); + $this->assertMatchesRegularExpression('/"title":"PhpAttributeTest_\w+"/', $jsonSchemaArg); + $this->assertSame(Source::class, $classAttributes[2]->getName()); + $this->assertStringEndsWith('.json', $classAttributes[2]->getArguments()[0]); + + $propertyAttributes = new ReflectionClass($object)->getProperties()[0]->getAttributes(); + $this->assertCount(3, $propertyAttributes); + + $this->assertSame(JsonPointer::class, $propertyAttributes[0]->getName()); + $this->assertSame('/properties/my property', $propertyAttributes[0]->getArguments()[0]); + $this->assertSame(SchemaName::class, $propertyAttributes[1]->getName()); + $this->assertSame('my property', $propertyAttributes[1]->getArguments()[0]); + $this->assertSame(JsonSchema::class, $propertyAttributes[2]->getName()); + $this->assertSame( + '{"type":"string","deprecated":false,"readOnly":true}', + $propertyAttributes[2]->getArguments()[0], ); - - $source = $this->getGeneratedSource(); - - $this->assertStringContainsString('#[SameNsAttr]', $source); - $this->assertStringNotContainsString('use MyApp\\Model\\SameNsAttr;', $source); - } - - // ------------------------------------------------------------------------- - // Attribute placement in generated source - // ------------------------------------------------------------------------- - - public function testClassAttributeAppearsBeforeClassKeyword(): void - { - $this->addClassAttribute(new PhpAttribute(self::FQCN_CLASS_ATTR)); - $this->generateClassFromFile('BasicSchema.json'); - - $source = $this->getGeneratedSource(); - - // The #[ClassAttr] line must come before the 'class ClassName' declaration line. - // Use "\nclass " to match the class keyword at the start of a line, not occurrences - // inside docblock comments (e.g. "auto-implemented class implemented by..."). - $attrPos = strpos($source, '#[ClassAttr]'); - $classPos = strpos($source, "\nclass "); - - $this->assertNotFalse($attrPos); - $this->assertNotFalse($classPos); - $this->assertLessThan($classPos, $attrPos); - } - - public function testPropertyAttributeAppearsBeforePropertyDeclaration(): void - { - $this->addPropertyAttribute('name', new PhpAttribute(self::FQCN_PROPERTY_ATTR)); - $this->generateClassFromFile('BasicSchema.json'); - - $source = $this->getGeneratedSource(); - - $attrPos = strpos($source, '#[PropertyAttr]'); - $propPos = strpos($source, '$name'); - - $this->assertNotFalse($attrPos); - $this->assertNotFalse($propPos); - $this->assertLessThan($propPos, $attrPos); } } diff --git a/tests/Basic/PropertyNamesTest.php b/tests/Basic/PropertyNamesTest.php index 55d4be03..42dcce24 100644 --- a/tests/Basic/PropertyNamesTest.php +++ b/tests/Basic/PropertyNamesTest.php @@ -210,4 +210,31 @@ public function testInvalidConstPropertyNamesThrowsAnException(): void $this->generateClassFromFileTemplate('PropertyNames.json', ['{"const": false}'], escape: false); } + + #[DataProvider('nonStringPropertyNamesTypeDataProvider')] + public function testNonStringTypeInPropertyNamesThrowsSchemaException(string $type): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + "/Invalid type '$type' for propertyNames schema in file/", + ); + + $this->generateClassFromFileTemplate( + 'PropertyNames.json', + [sprintf('{"type": "%s"}', $type)], + escape: false, + ); + } + + public static function nonStringPropertyNamesTypeDataProvider(): array + { + return [ + 'integer' => ['integer'], + 'number' => ['number'], + 'boolean' => ['boolean'], + 'array' => ['array'], + 'object' => ['object'], + 'null' => ['null'], + ]; + } } diff --git a/tests/ComposedValue/ComposedAllOfTest.php b/tests/ComposedValue/ComposedAllOfTest.php index 4fe08bfe..da731c2c 100644 --- a/tests/ComposedValue/ComposedAllOfTest.php +++ b/tests/ComposedValue/ComposedAllOfTest.php @@ -124,6 +124,8 @@ public function testNotProvidedObjectLevelAllOfMatchingAllOptionsIsValid(): void $object = new $className([]); $this->assertEmpty($object->getIntegerProperty()); $this->assertEmpty($object->getStringProperty()); + $this->assertPropertyHasJsonPointer($object, 'stringProperty', '/allOf/0/properties/stringProperty'); + $this->assertPropertyHasJsonPointer($object, 'integerProperty', '/allOf/1/properties/integerProperty'); } public function testAllOfTypePropertyHasTypeAnnotation(): void @@ -538,6 +540,35 @@ public static function validationInSetterDataProvider(): array ]; } + /** + * An object-level `allOf` schema that also carries non-composition schema-level validators + * (here: `minProperties`) must generate correctly and enforce all constraints. + * + * During SchemaProcessor::transferComposedPropertiesToSchema the base property's validator + * list contains both a TypeCheckValidator (from TypeCheckModifier) and a MinProperties + * validator — neither of which is an AbstractComposedPropertyValidator — so both are skipped + * via the `continue` guard (line 472) before the allOf composition validator is processed. + */ + public function testObjectLevelAllOfWithAdditionalBaseValidatorTransfersProperties(): void + { + $className = $this->generateClassFromFile('ObjectLevelCompositionWithMinProperties.json'); + + // Properties from both allOf branches are accessible. + $object = new $className(['name' => 'Alice', 'age' => 30]); + $this->assertSame('Alice', $object->getName()); + $this->assertSame(30, $object->getAge()); + } + + public function testObjectLevelAllOfWithMinPropertiesRejectsEmptyObject(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessageMatches('/must not contain less than 1 properties/'); + + $className = $this->generateClassFromFile('ObjectLevelCompositionWithMinProperties.json'); + + new $className([]); + } + public function testIdenticalMergedSchemaIsRedirected(): void { $className = $this->generateClassFromFile( diff --git a/tests/ComposedValue/ComposedAnyOfTest.php b/tests/ComposedValue/ComposedAnyOfTest.php index ef159723..4f2092c0 100644 --- a/tests/ComposedValue/ComposedAnyOfTest.php +++ b/tests/ComposedValue/ComposedAnyOfTest.php @@ -166,6 +166,8 @@ public function testNotProvidedObjectLevelAnyOfMatchingAllOptionsIsValid(): void $object = new $className([]); $this->assertEmpty($object->getIntegerProperty()); $this->assertEmpty($object->getStringProperty()); + $this->assertPropertyHasJsonPointer($object, 'stringProperty', '/anyOf/0/properties/stringProperty'); + $this->assertPropertyHasJsonPointer($object, 'integerProperty', '/anyOf/1/properties/integerProperty'); } #[DataProvider('validPropertyTypeDataProvider')] diff --git a/tests/ComposedValue/ComposedIfTest.php b/tests/ComposedValue/ComposedIfTest.php index f088cb13..80a199d2 100644 --- a/tests/ComposedValue/ComposedIfTest.php +++ b/tests/ComposedValue/ComposedIfTest.php @@ -46,6 +46,7 @@ public function testConditionalPropertyDefinition(int $value): void $object = new $className(['property' => $value]); $this->assertSame($value, $object->getProperty()); + $this->assertPropertyHasJsonPointer($object, 'property', '/properties/property'); } public static function validConditionalPropertyDefinitionDataProvider(): array @@ -61,7 +62,8 @@ public static function validConditionalPropertyDefinitionDataProvider(): array } #[DataProvider('invalidConditionalPropertyDefinitionDataProvider')] - public function testInvalidConditionalPropertyDefinition(int $value, string $expectedExceptionMessage): void { + public function testInvalidConditionalPropertyDefinition(int $value, string $expectedExceptionMessage): void + { $this->expectException(ConditionalException::class); $this->expectExceptionMessage($expectedExceptionMessage); @@ -271,6 +273,8 @@ public function testIfOnlyPropertyIsTransferredToParentSchema(): void $object = new $className(['qualifier' => 'test', 'value' => 42]); $this->assertSame('test', $object->getQualifier()); $this->assertSame(42, $object->getValue()); + $this->assertPropertyHasJsonPointer($object, 'qualifier', '/if/properties/qualifier'); + $this->assertPropertyHasJsonPointer($object, 'value', '/then/properties/value'); } public function testExclusiveBranchPropertiesAreTransferred(): void @@ -294,5 +298,8 @@ public function testExclusiveBranchPropertiesAreTransferred(): void $this->assertSame('numeric', $object->getKind()); $this->assertSame(5, $object->getAmount()); $this->assertSame('hello', $object->getLabel()); + $this->assertPropertyHasJsonPointer($object, 'kind', '/if/properties/kind'); + $this->assertPropertyHasJsonPointer($object, 'amount', '/then/properties/amount'); + $this->assertPropertyHasJsonPointer($object, 'label', '/else/properties/label'); } } diff --git a/tests/ComposedValue/ComposedNotTest.php b/tests/ComposedValue/ComposedNotTest.php index 5f2d85dd..d257b2ab 100644 --- a/tests/ComposedValue/ComposedNotTest.php +++ b/tests/ComposedValue/ComposedNotTest.php @@ -187,6 +187,20 @@ public static function validNotNullPropertyDataProvider(): array * @throws RenderException * @throws SchemaException */ + #[DataProvider('validationMethodDataProvider')] + public function testObjectLevelNot(GeneratorConfiguration $configuration): void + { + $className = $this->generateClassFromFile('ObjectLevelNot.json', $configuration); + + // An empty object does not satisfy the `not` branch (required 'name' is absent), so it is valid. + $object = new $className([]); + $this->assertSame([], $object->getRawModelDataInput()); + + // Providing the required 'name' property satisfies the `not` branch → validation fails. + $this->expectValidationError($configuration, 'declined by composition constraint'); + new $className(['name' => 'Alice']); + } + #[DataProvider('validExtendedPropertyDataProvider')] public function testExtendedPropertyDefinitionWithValidValues( GeneratorConfiguration $configuration, diff --git a/tests/Draft/DraftExtensibilityTest.php b/tests/Draft/DraftExtensibilityTest.php new file mode 100644 index 00000000..aafcdb42 --- /dev/null +++ b/tests/Draft/DraftExtensibilityTest.php @@ -0,0 +1,238 @@ +expectException(SchemaException::class); + $this->expectExceptionMessageMatches('/Invalid minLength/'); + + $this->generateClassFromFile('StringWithFloatMinLength.json'); + } + + /** + * A custom draft overrides the built-in 'minLength' validator (float-accepting replacement), + * adds a new 'customMin' validator, and registers a brand-new 'special' type — all in one + * pass. + * + * Override mechanism: Type::addValidator keys validators by name, so a second call with the + * same key replaces the existing entry in-place (preserving its position in the modifier + * sequence). + * Add-new-validator mechanism: DraftBuilder::getType returns the live Type object; calling + * addValidator on it mutates the builder's entry directly. + * New-type mechanism: DraftBuilder::addType with a previously-unknown key adds a new entry. + */ + public function testCustomDraftExtensibilityOverrideAndAddAndNewType(): void + { + // Factory that accepts any numeric minLength value (floats included). + // The exception threshold is cast to int to satisfy MinLengthException's signature. + $floatMinLengthFactory = new class extends SimplePropertyValidatorFactory { + protected function isValueValid(mixed $value): bool + { + return is_numeric($value) && $value >= 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + $intThreshold = (int) ceil((float) $value); + + return new PropertyValidator( + $property, + "is_string(\$value) && mb_strlen(\$value) < $intThreshold", + MinLengthException::class, + [$intThreshold], + ); + } + }; + + // Factory for the new 'customMin' keyword — enforces a minimum string length. + $customMinFactory = new class extends SimplePropertyValidatorFactory { + protected function isValueValid(mixed $value): bool + { + return is_int($value) && $value >= 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + "is_string(\$value) && mb_strlen(\$value) < $value", + MinLengthException::class, + [$value], + ); + } + }; + + $customDraft = new class ($floatMinLengthFactory, $customMinFactory) implements DraftInterface { + public function __construct( + private readonly SimplePropertyValidatorFactory $floatMinLength, + private readonly SimplePropertyValidatorFactory $customMin, + ) {} + + public function getDefinition(): DraftBuilder + { + $builder = (new Draft_07())->getDefinition(); + + // Override the built-in 'minLength' validator with one that accepts floats. + // addValidator keys by name, so this replaces the existing entry in-place. + $builder->getType('string')->addValidator('minLength', $this->floatMinLength); + + // Add a brand-new 'customMin' validator to the string type. + $builder->getType('string')->addValidator('customMin', $this->customMin); + + // Add a completely new type. + $builder->addType(new Type('special')); + + return $builder; + } + }; + + // Verify that the new 'special' type is visible via getTypes() and hasType(). + $builtDraft = $customDraft->getDefinition()->build(); + $this->assertTrue($builtDraft->hasType('special')); + $this->assertArrayHasKey('special', $builtDraft->getTypes()); + $this->assertFalse($builtDraft->hasType('nonexistent')); + + $config = (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setDraft($customDraft); + + // Override verified: float minLength no longer throws SchemaException at gen time. + // minLength: 0.5 → ceil(0.5) = 1 → minimum effective length is 1. + $className = $this->generateClassFromFile('StringWithFloatMinLength.json', $config); + + // Non-empty string passes (length 3 >= 1). + $object = new $className(['value' => 'abc']); + $this->assertSame('abc', $object->getValue()); + + // Empty string fails (length 0 < 1). + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Value for value must not be shorter than 1'); + new $className(['value' => '']); + } + + public function testCustomDraftCustomMinKeywordEnforcesMinimumLengthAtRuntime(): void + { + // Factory for the new 'customMin' keyword — same runtime semantics as minLength. + $customMinFactory = new class extends SimplePropertyValidatorFactory { + protected function isValueValid(mixed $value): bool + { + return is_int($value) && $value >= 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + "is_string(\$value) && mb_strlen(\$value) < $value", + MinLengthException::class, + [$value], + ); + } + }; + + $customDraft = new class ($customMinFactory) implements DraftInterface { + public function __construct( + private readonly SimplePropertyValidatorFactory $customMin, + ) {} + + public function getDefinition(): DraftBuilder + { + $builder = (new Draft_07())->getDefinition(); + // Mutate the live Type object returned by getType — this is the "add validator + // to existing type" path that does not need a full type replacement. + $builder->getType('string')->addValidator('customMin', $this->customMin); + + return $builder; + } + }; + + $config = (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setDraft($customDraft); + + $className = $this->generateClassFromFile('StringWithCustomMin.json', $config); + + // 'ab' has length 2 < 3 → validation fails + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Value for value must not be shorter than 3'); + new $className(['value' => 'ab']); + } + + public function testCustomDraftCustomMinKeywordAcceptsValueMeetingMinimum(): void + { + $customMinFactory = new class extends SimplePropertyValidatorFactory { + protected function isValueValid(mixed $value): bool + { + return is_int($value) && $value >= 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + "is_string(\$value) && mb_strlen(\$value) < $value", + MinLengthException::class, + [$value], + ); + } + }; + + $customDraft = new class ($customMinFactory) implements DraftInterface { + public function __construct( + private readonly SimplePropertyValidatorFactory $customMin, + ) {} + + public function getDefinition(): DraftBuilder + { + $builder = (new Draft_07())->getDefinition(); + $builder->getType('string')->addValidator('customMin', $this->customMin); + + return $builder; + } + }; + + $config = (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setDraft($customDraft); + + $className = $this->generateClassFromFile('StringWithCustomMin.json', $config); + + $object = new $className(['value' => 'abc']); + $this->assertSame('abc', $object->getValue()); + + $object = new $className(['value' => 'longer string']); + $this->assertSame('longer string', $object->getValue()); + } +} diff --git a/tests/Draft/DraftTest.php b/tests/Draft/DraftTest.php new file mode 100644 index 00000000..cd8b1bf4 --- /dev/null +++ b/tests/Draft/DraftTest.php @@ -0,0 +1,140 @@ +getDefinition(); + + $type = $builder->getType('string'); + + $this->assertInstanceOf(Type::class, $type); + $this->assertSame('string', $type->getType()); + } + + public function testDraftBuilderGetTypeReturnsNullForUnknownType(): void + { + $builder = (new Draft_07())->getDefinition(); + + $this->assertNull($builder->getType('nonexistent')); + } + + // --- Draft::getTypes / Draft::hasType --- + + public function testDraftGetTypesReturnsAllRegisteredTypes(): void + { + $types = (new Draft_07())->getDefinition()->build()->getTypes(); + + $this->assertArrayHasKey('string', $types); + $this->assertArrayHasKey('integer', $types); + $this->assertArrayHasKey('number', $types); + $this->assertArrayHasKey('boolean', $types); + $this->assertArrayHasKey('array', $types); + $this->assertArrayHasKey('object', $types); + $this->assertArrayHasKey('null', $types); + $this->assertArrayHasKey('any', $types); + $this->assertCount(8, $types); + } + + public function testDraftHasTypeReturnsTrueForRegisteredType(): void + { + $draft = (new Draft_07())->getDefinition()->build(); + + $this->assertTrue($draft->hasType('string')); + $this->assertTrue($draft->hasType('integer')); + $this->assertTrue($draft->hasType('any')); + } + + public function testDraftHasTypeReturnsFalseForUnknownType(): void + { + $draft = (new Draft_07())->getDefinition()->build(); + + $this->assertFalse($draft->hasType('nonexistent')); + $this->assertFalse($draft->hasType('custom')); + } + + // --- Draft / getCoveredTypes contract --- + + public function testGetCoveredTypesThrowsForUnknownType(): void + { + $this->expectException(SchemaException::class); + + (new Draft_07())->getDefinition()->build()->getCoveredTypes('nonexistent'); + } + + // --- AutoDetectionDraft --- + + public function testAutoDetectionReturnsDraft07ForDraft07SchemaKeyword(): void + { + $jsonSchema = new JsonSchema('test.json', [ + '$schema' => 'http://json-schema.org/draft-07/schema#', + ]); + + $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + } + + public function testAutoDetectionFallsBackToDraft07WhenSchemaKeywordAbsent(): void + { + $jsonSchema = new JsonSchema('test.json', ['type' => 'object']); + + $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + } + + public function testAutoDetectionFallsBackToDraft07ForUnrecognisedSchemaKeyword(): void + { + $jsonSchema = new JsonSchema('test.json', ['$schema' => 'https://example.com/custom-schema']); + + $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + } + + // --- GeneratorConfiguration --- + + public function testGeneratorConfigurationDefaultDraftIsAutoDetection(): void + { + $this->assertInstanceOf(AutoDetectionDraft::class, (new GeneratorConfiguration())->getDraft()); + } + + public function testGeneratorConfigurationAcceptsDraftInterface(): void + { + $draft = new Draft_07(); + $config = (new GeneratorConfiguration())->setDraft($draft); + + $this->assertSame($draft, $config->getDraft()); + } + + public function testGeneratorConfigurationAcceptsDraftFactoryInterface(): void + { + $factory = new AutoDetectionDraft(); + $config = (new GeneratorConfiguration())->setDraft($factory); + + $this->assertSame($factory, $config->getDraft()); + } + + public function testGeneratorConfigurationSetDraftReturnsSelf(): void + { + $config = new GeneratorConfiguration(); + + $this->assertSame($config, $config->setDraft(new Draft_07())); + } + + public function testGeneratorConfigurationSetDraftFactoryReturnsSelf(): void + { + $config = new GeneratorConfiguration(); + + $this->assertSame($config, $config->setDraft(new AutoDetectionDraft())); + } +} diff --git a/tests/Draft/Modifier/DefaultValueModifierTest.php b/tests/Draft/Modifier/DefaultValueModifierTest.php new file mode 100644 index 00000000..1c5ee7db --- /dev/null +++ b/tests/Draft/Modifier/DefaultValueModifierTest.php @@ -0,0 +1,121 @@ +schemaProcessor = new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + new GeneratorConfiguration(), + new RenderQueue(), + ); + + $this->schema = new Schema('', '', '', new JsonSchema('', [])); + } + + private function modifier(): DefaultValueModifier + { + return new DefaultValueModifier(); + } + + public function testSetsDefaultValueWhenPresent(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'string', 'default' => 'hello']); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame("'hello'", $property->getDefaultValue()); + } + + public function testDoesNothingWhenNoDefaultPresent(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'string']); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertNull($property->getDefaultValue()); + } + + public function testThrowsForInvalidDefaultType(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessage('Invalid type for default value of property name'); + + $jsonSchema = new JsonSchema('test.json', ['type' => 'string', 'default' => 42]); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + } + + public function testCoercesIntegerDefaultToFloatForNumberType(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'number', 'default' => 5]); + $property = new Property('price', new PropertyType('float'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame('5.0', $property->getDefaultValue()); + } + + public function testThrowsForNonNumericDefaultOnNumberType(): void + { + $this->expectException(SchemaException::class); + + $jsonSchema = new JsonSchema('test.json', ['type' => 'number', 'default' => 'not-a-number']); + $property = new Property('price', new PropertyType('float'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + } + + public function testIntegerDefaultValue(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'integer', 'default' => 42]); + $property = new Property('count', new PropertyType('integer'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame('42', $property->getDefaultValue()); + } + + public function testBooleanDefaultValue(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'boolean', 'default' => true]); + $property = new Property('flag', new PropertyType('boolean'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame('true', $property->getDefaultValue()); + } + + public function testSetsDefaultValueWhenNoTypeInSchema(): void + { + $jsonSchema = new JsonSchema('', ['default' => 'anything']); + $property = new Property('val', new PropertyType('string'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame("'anything'", $property->getDefaultValue()); + } +} diff --git a/tests/Draft/Modifier/TypeCheckModifierTest.php b/tests/Draft/Modifier/TypeCheckModifierTest.php new file mode 100644 index 00000000..f7d941b2 --- /dev/null +++ b/tests/Draft/Modifier/TypeCheckModifierTest.php @@ -0,0 +1,172 @@ +schemaProcessor = new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + new GeneratorConfiguration(), + new RenderQueue(), + ); + + $this->schema = new Schema('', '', '', new JsonSchema('', [])); + } + + public function testAddsTypeCheckValidatorForType(): void + { + $property = new Property('name', new PropertyType('string'), new JsonSchema('', ['type' => 'string'])); + $property->setRequired(true); + + $modifier = new TypeCheckModifier('string'); + $modifier->modify($this->schemaProcessor, $this->schema, $property, new JsonSchema('', ['type' => 'string'])); + + $typeCheckValidators = array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + ); + + $this->assertCount(1, $typeCheckValidators); + $this->assertContains('string', reset($typeCheckValidators)->getValidator()->getTypes()); + } + + public function testDoesNotAddDuplicateTypeCheckValidator(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'string']); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + $property->setRequired(true); + + // Add a TypeCheckValidator manually first + $property->addValidator(new TypeCheckValidator('string', $property, false), 2); + + $modifier = new TypeCheckModifier('string'); + $modifier->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $typeCheckValidators = array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + ); + + $this->assertCount(1, $typeCheckValidators); + } + + public function testDoesNotSkipWhenExistingValidatorIsForDifferentType(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'integer']); + $property = new Property('name', new PropertyType('int'), $jsonSchema); + $property->setRequired(true); + + // Add a TypeCheckValidator for a different type + $property->addValidator(new TypeCheckValidator('string', $property, false), 2); + + $modifier = new TypeCheckModifier('int'); + $modifier->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $typeCheckValidators = array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + ); + + $this->assertCount(2, $typeCheckValidators); + } + + // --- Type auto-registration --- + + #[DataProvider('typeCheckAutoRegistrationProvider')] + public function testTypeAutoRegistersTypeCheckModifier(string $jsonType, string $phpType): void + { + $type = new Type($jsonType); + + $modifiers = $type->getModifiers(); + $this->assertCount(1, $modifiers); + $this->assertInstanceOf(TypeCheckModifier::class, $modifiers[0]); + + // Verify the modifier adds a validator for the correct PHP type + $property = new Property('x', new PropertyType($phpType), new JsonSchema('', ['type' => $jsonType])); + $property->setRequired(true); + $schema = new Schema('', '', '', new JsonSchema('', [])); + $schemaProcessor = new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + new GeneratorConfiguration(), + new RenderQueue(), + ); + + $modifiers[0]->modify($schemaProcessor, $schema, $property, new JsonSchema('', ['type' => $jsonType])); + + $typeCheckValidators = array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + ); + $this->assertCount(1, $typeCheckValidators); + $this->assertContains($phpType, reset($typeCheckValidators)->getValidator()->getTypes()); + } + + public static function typeCheckAutoRegistrationProvider(): array + { + return [ + 'array' => ['array', 'array'], + 'string' => ['string', 'string'], + 'integer' => ['integer', 'int'], + 'number' => ['number', 'float'], + 'boolean' => ['boolean', 'bool'], + 'null' => ['null', 'null'], + ]; + } + + public function testTypeWithTypeCheckFalseRegistersNoModifiers(): void + { + $type = new Type('object', false); + $this->assertEmpty($type->getModifiers()); + } + + public function testAllowsImplicitNullForOptionalProperty(): void + { + $config = (new GeneratorConfiguration())->setImplicitNull(true); + $schemaProcessor = new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + $config, + new RenderQueue(), + ); + + $jsonSchema = new JsonSchema('', ['type' => 'string']); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + $property->setRequired(false); + + $modifier = new TypeCheckModifier('string'); + $modifier->modify($schemaProcessor, $this->schema, $property, $jsonSchema); + + $validators = array_values(array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + )); + + $this->assertCount(1, $validators); + // The check should allow $value === null for an optional property with implicit null enabled + $this->assertStringContainsString('null', $validators[0]->getValidator()->getCheck()); + } +} diff --git a/tests/Issues/Issue/Issue103Test.php b/tests/Issues/Issue/Issue103Test.php new file mode 100644 index 00000000..386b5e00 --- /dev/null +++ b/tests/Issues/Issue/Issue103Test.php @@ -0,0 +1,195 @@ +setSerialization(true) + ->setImmutable(false) + ->setImplicitNull($implicitNull); + } + + /** + * Schema-name serialization: toArray, toJSON, jsonSerialize all use the original JSON Schema + * property names as output keys. The $except list takes schema names; passing a PHP camelCase + * name must NOT suppress the property. Custom serializer methods must also land under the + * schema-name key. + */ + public function testSchemaNameSerializationOutputAndExcept(): void + { + $className = $this->generateClassFromFile( + 'NonCamelCaseProperties.json', + $this->serializationConfig(), + false, + false, + ); + + $object = new $className(['product_id' => 'abc-123']); + + // toArray uses schema name key + $result = $object->toArray(); + $this->assertArrayHasKey('product_id', $result); + $this->assertSame('abc-123', $result['product_id']); + $this->assertArrayNotHasKey('productId', $result); + + // toJSON uses schema name key + $decoded = json_decode($object->toJSON(), true); + $this->assertArrayHasKey('product_id', $decoded); + $this->assertSame('abc-123', $decoded['product_id']); + $this->assertArrayNotHasKey('productId', $decoded); + + // jsonSerialize uses schema name key + $jsResult = $object->jsonSerialize(); + $this->assertArrayHasKey('product_id', $jsResult); + $this->assertArrayNotHasKey('productId', $jsResult); + + // $except takes schema name; PHP camelCase does NOT suppress + $exceptResult = $object->toArray(['productId']); + $this->assertArrayHasKey('product_id', $exceptResult); + + // Custom serializer: method serializeProductId is called; result lands under 'product_id' + $subclassName = 'CustomSerializer103_' . md5($className); + if (!class_exists($subclassName)) { + eval("class $subclassName extends $className { + protected function serializeProductId() { + return strtoupper(\$this->productId); + } + }"); + } + + $custom = new $subclassName(['product_id' => 'abc']); + $customResult = $custom->toArray(); + $this->assertArrayHasKey('product_id', $customResult); + $this->assertSame('ABC', $customResult['product_id']); + $this->assertArrayNotHasKey('productId', $customResult); + } + + /** + * Kebab/space schema names, round-trip, and $except-with-schema-name, tested together because + * they all require implicitNull=true on the same schema file. + */ + public function testKebabSpaceNamesRoundTripAndExcept(): void + { + $className = $this->generateClassFromFile( + 'NonCamelCaseProperties.json', + $this->serializationConfig(true), + false, + true, + ); + + $input = [ + 'product_id' => 'sku-99', + 'my-thing' => 'foo', + 'my property' => 'bar', + ]; + $object = new $className($input); + $result = $object->toArray(); + + // Kebab and space schema names preserved in output + $this->assertArrayHasKey('my-thing', $result); + $this->assertSame('foo', $result['my-thing']); + $this->assertArrayNotHasKey('myThing', $result); + + $this->assertArrayHasKey('my property', $result); + $this->assertSame('bar', $result['my property']); + $this->assertArrayNotHasKey('myProperty', $result); + + // Round-trip: serialized output feeds back into the constructor without errors + $reconstructed = new $className($result); + $this->assertSame($result, $reconstructed->toArray()); + + // $except with schema name suppresses the property + $exceptResult = $object->toArray(['product_id']); + $this->assertArrayNotHasKey('product_id', $exceptResult); + $this->assertArrayHasKey('my-thing', $exceptResult); + } + + /** + * skipNotProvidedPropertiesMap regression: an optional property that was not supplied must be + * absent from the output; it must appear once set via the setter. + * + * Previously broken because skipNotProvidedPropertiesMap stored PHP attribute names while + * rawModelDataInput is keyed by schema names, so the array_diff comparison never matched. + */ + public function testOptionalPropertySkippedWhenAbsentAppearsAfterSetter(): void + { + $className = $this->generateClassFromFile( + 'OptionalNonCamelCaseProperty.json', + $this->serializationConfig(false), + false, + false, + ); + + // Only the required field is provided; product_id must be absent + $object = new $className(['required_field' => 'hello']); + $result = $object->toArray(); + $this->assertArrayHasKey('required_field', $result); + $this->assertArrayNotHasKey('product_id', $result); + $this->assertArrayNotHasKey('productId', $result); + + // After setter, the property appears under its schema name + $object->setProductId('world'); + $this->assertSame('world', $object->toArray()['product_id']); + } + + /** + * Nested objects serialize using schema names at every level. Depth budget propagates + * correctly across nested models. The capability cache is not poisoned by an earlier + * depth-exhausted call. + */ + public function testNestedObjectsDepthBudgetAndCapabilityCache(): void + { + $className = $this->generateClassFromFile( + 'NestedNonCamelCaseObjects.json', + $this->serializationConfig(true), + false, + true, + ); + + $object = new $className([ + 'product_id' => 'sku-1', + 'nested_object' => ['inner_value' => 'hello'], + ]); + + // Full serialization: schema names at every level + $full = $object->toArray(); + $this->assertArrayHasKey('product_id', $full); + $this->assertArrayNotHasKey('productId', $full); + $this->assertArrayHasKey('nested_object', $full); + $this->assertArrayNotHasKey('nestedObject', $full); + $this->assertArrayHasKey('inner_value', $full['nested_object']); + $this->assertArrayNotHasKey('innerValue', $full['nested_object']); + $this->assertSame('hello', $full['nested_object']['inner_value']); + + // depth=1: nested object budget exhausted → null + $atDepth1 = $object->toArray([], 1); + $this->assertSame('sku-1', $atDepth1['product_id']); + $this->assertNull($atDepth1['nested_object']); + + // depth=2: one nesting level fully serialized + $atDepth2 = $object->toArray([], 2); + $this->assertSame('sku-1', $atDepth2['product_id']); + $this->assertIsArray($atDepth2['nested_object']); + $this->assertSame('hello', $atDepth2['nested_object']['inner_value']); + + // Capability cache must not be poisoned by the earlier depth-1 call: calling at + // default depth after a depth-exhausted call must still serialize correctly. + $afterPoison = $object->toArray(); + $this->assertIsArray($afterPoison['nested_object']); + $this->assertSame('hello', $afterPoison['nested_object']['inner_value']); + } +} diff --git a/tests/Issues/Issue/Issue116Test.php b/tests/Issues/Issue/Issue116Test.php index 836544c8..262b7652 100644 --- a/tests/Issues/Issue/Issue116Test.php +++ b/tests/Issues/Issue/Issue116Test.php @@ -6,7 +6,6 @@ use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Tests\Issues\AbstractIssueTestCase; -use PHPUnit\Framework\Attributes\DataProvider; /** * Issue #116: When an external schema file contains a $ref pointing to another schema file and @@ -117,16 +116,16 @@ public function testAdditionalPropertiesRefGeneratesCorrectTypes(): void $this->assertNotNull($metricsClassName, 'getMetrics() must have a non-null return type'); - // The doc comment on the _additionalProperties property inside the Metrics class must + // The doc comment on the additionalProperties property inside the Metrics class must // reference the canonical ForecastRange, not the nested duplicate - $additionalPropertiesAnnotation = $this->getPropertyTypeAnnotation($metricsClassName, '_additionalProperties'); + $additionalPropertiesAnnotation = $this->getPropertyTypeAnnotation($metricsClassName, 'additionalProperties'); // The annotation uses the short class name (imports handle the FQCN); check for it. $shortRangeClass = substr($rangeClass, strrpos($rangeClass, '\\') + 1); $this->assertStringContainsString( $shortRangeClass, $additionalPropertiesAnnotation, - '_additionalProperties @var annotation must reference the standalone ForecastRange class', + 'additionalProperties @var annotation must reference the standalone ForecastRange class', ); // Must NOT reference a nested duplicate (e.g. ForecastPoint_AdditionalProperty) $this->assertStringNotContainsString( diff --git a/tests/Issues/Issue/Issue70Test.php b/tests/Issues/Issue/Issue70Test.php index 18a1ac67..9e94352d 100644 --- a/tests/Issues/Issue/Issue70Test.php +++ b/tests/Issues/Issue/Issue70Test.php @@ -44,11 +44,6 @@ public static function validInputDataProvider(): array public function getFilter(): TransformingFilterInterface { return new class () implements TransformingFilterInterface { - public function getAcceptedTypes(): array - { - return ['string', 'null']; - } - public function getToken(): string { return 'countChars'; diff --git a/tests/Model/Validator/PassThroughTypeCheckValidatorTest.php b/tests/Model/Validator/PassThroughTypeCheckValidatorTest.php new file mode 100644 index 00000000..922b4b9f --- /dev/null +++ b/tests/Model/Validator/PassThroughTypeCheckValidatorTest.php @@ -0,0 +1,40 @@ +assertSame(['int', 'string'], $validator->getTypes()); + } + + public function testGetTypesDeduplicate(): void + { + $property = new Property('test', new PropertyType('string'), new JsonSchema('', [])); + $typeCheckValidator = new TypeCheckValidator('string', $property, false); + + $validator = new PassThroughTypeCheckValidator(['string'], $property, $typeCheckValidator); + + // Duplicate between inner validator type and pass-through type is removed. + $this->assertSame(['string'], $validator->getTypes()); + } +} diff --git a/tests/Objects/ArrayPropertyTest.php b/tests/Objects/ArrayPropertyTest.php index 5fec14e7..e0f0aca4 100644 --- a/tests/Objects/ArrayPropertyTest.php +++ b/tests/Objects/ArrayPropertyTest.php @@ -673,6 +673,11 @@ public function testValidObjectArray( $this->assertSame($propertyValue[$key]['name'], $person->getName()); $this->assertSame($propertyValue[$key]['age'] ?? null, $person->getAge()); + + if ($file === 'ArrayPropertyNestedObject.json') { + $this->assertClassHasJsonPointer($person, '/properties/property/items'); + $this->assertPropertyHasJsonPointer($person, 'name', '/properties/property/items/properties/name'); + } } } @@ -909,4 +914,35 @@ public static function invalidRecursiveArrayDataProvider(): array 'invalid nested type' => [InvalidItemException::class, ['Hello', [2]]], ]; } + + /** + * An invalid maxItems value in the schema (non-integer or negative) must throw + * SchemaException at generation time. + * Covers SimplePropertyValidatorFactory::hasValidValue throwing SchemaException. + */ + #[DataProvider('invalidMaxItemsValueDataProvider')] + public function testInvalidMaxItemsValueThrowsSchemaException(mixed $maxItems): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches('/Invalid maxItems .* for property/'); + + $this->generateClass( + json_encode([ + 'type' => 'object', + 'properties' => [ + 'list' => ['type' => 'array', 'maxItems' => $maxItems], + ], + ]), + ); + } + + public static function invalidMaxItemsValueDataProvider(): array + { + return [ + 'float' => [1.5], + 'negative int' => [-1], + 'string value' => ['ten'], + 'boolean' => [true], + ]; + } } diff --git a/tests/Objects/ConstPropertyTest.php b/tests/Objects/ConstPropertyTest.php index 54827b39..69616c86 100644 --- a/tests/Objects/ConstPropertyTest.php +++ b/tests/Objects/ConstPropertyTest.php @@ -226,7 +226,7 @@ public function testProvidedConstPropertiesIsValidWithDifferentImplicitNull( ): void { $className = $this->generateClassFromFile( 'RequiredAndOptionalConstProperties.json', - new GeneratorConfiguration(), + (new GeneratorConfiguration())->setImmutable(false), false, $implicitNull, ); @@ -244,8 +244,7 @@ public function testProvidedConstPropertiesIsValidWithDifferentImplicitNull( $this->assertSame('string', $returnType->getName()); $this->assertFalse($returnType->allowsNull()); - $this->assertSame('string', $this->getParameterTypeAnnotation($className, 'setRequiredProperty'), - ); + $this->assertSame('string', $this->getParameterTypeAnnotation($className, 'setRequiredProperty'),); $setAgeParamType = $this->getParameterType($className, 'setRequiredProperty'); $this->assertSame('string', $setAgeParamType->getName()); $this->assertFalse($returnType->allowsNull()); @@ -303,8 +302,7 @@ public function testNotMatchingRequiredAndOptionalProvidedDataThrowsAnException( string $reqPropertyValue, ?string $optPropertyValue, string $exceptionMessage - ): void - { + ): void { $className = $this->generateClassFromFile( 'RequiredAndOptionalConstProperties.json', new GeneratorConfiguration(), @@ -332,6 +330,18 @@ public static function invalidRequiredAndOptionalConstPropertiesDataProvider(): ); } + public function testConstPropertyHasNoSetterWhenImmutable(): void + { + $className = $this->generateClassFromFile('RequiredAndOptionalConstProperties.json'); + + $object = new $className(['requiredProperty' => 'red']); + + $this->assertTrue(is_callable([$object, 'getRequiredProperty'])); + $this->assertFalse(is_callable([$object, 'setRequiredProperty'])); + $this->assertTrue(is_callable([$object, 'getOptionalProperty'])); + $this->assertFalse(is_callable([$object, 'setOptionalProperty'])); + } + #[DataProvider('implicitNullDataProvider')] public function testProvidedNullValueConstPropertyIsValid(bool $implicitNull): void { diff --git a/tests/Objects/MultiTypePropertyTest.php b/tests/Objects/MultiTypePropertyTest.php index 78695eee..895cd6e7 100644 --- a/tests/Objects/MultiTypePropertyTest.php +++ b/tests/Objects/MultiTypePropertyTest.php @@ -285,7 +285,7 @@ public static function invalidRecursiveMultiTypeDataProvider(): array ['Test1', 1], InvalidItemException::class, <<assertSame($input['name'] ?? null, ($object->getPerson()->getName())); $this->assertSame($input['age'] ?? null, ($object->getPerson()->getAge())); $this->assertSame($input, ($object->getPerson()->getRawModelDataInput())); + + // External standalone file references resolve at the root of that file (pointer ''), + // whereas path/id references into definitions resolve at '/definitions/person'. + $person = $object->getPerson(); + if (str_ends_with($reference, 'person.json')) { + $this->assertClassHasJsonPointer($person, ''); + $this->assertPropertyHasJsonPointer($person, 'name', '/properties/name'); + } else { + $this->assertClassHasJsonPointer($person, '/definitions/person'); + $this->assertPropertyHasJsonPointer($person, 'name', '/definitions/person/properties/name'); + } } } @@ -434,6 +445,7 @@ public static function nestedReferenceProvider(): array } #[DataProvider('nonResolvableExternalReferenceProvider')] + #[WithoutErrorHandler] public function testNonResolvableExternalReference(string $id, string $reference): void { $this->expectException(SchemaException::class); @@ -700,8 +712,7 @@ public function testMultiplePropertiesWithIdenticalReference(): void $object = new $className([ 'personA' => ['name' => 'Hannes'], - 'personB' => ['name' => 'Susi']], - ); + 'personB' => ['name' => 'Susi']],); $this->assertTrue(is_callable([$object, 'getPersonA'])); $this->assertTrue(is_callable([$object, 'getPersonB'])); diff --git a/tests/Objects/StringPropertyTest.php b/tests/Objects/StringPropertyTest.php index 05d185f6..4632251e 100644 --- a/tests/Objects/StringPropertyTest.php +++ b/tests/Objects/StringPropertyTest.php @@ -281,4 +281,35 @@ public static function invalidStringFormatDataProvider(): array 'mixed string' => ['1234a'], ]; } + + /** + * An invalid minLength value in the schema (non-integer or negative) must throw + * SchemaException at generation time. + * Covers SimplePropertyValidatorFactory::hasValidValue throwing SchemaException. + */ + #[DataProvider('invalidMinLengthValueDataProvider')] + public function testInvalidMinLengthValueThrowsSchemaException(mixed $minLength): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches('/Invalid minLength .* for property/'); + + $this->generateClass( + json_encode([ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'minLength' => $minLength], + ], + ]), + ); + } + + public static function invalidMinLengthValueDataProvider(): array + { + return [ + 'float' => [1.5], + 'negative int' => [-1], + 'string value' => ['two'], + 'boolean true' => [true], + ]; + } } diff --git a/tests/Objects/TupleArrayPropertyTest.php b/tests/Objects/TupleArrayPropertyTest.php index c3d5cde4..e6deaa4d 100644 --- a/tests/Objects/TupleArrayPropertyTest.php +++ b/tests/Objects/TupleArrayPropertyTest.php @@ -43,6 +43,12 @@ public function testValidValuesForTupleArray(GeneratorConfiguration $configurati $this->assertSame($propertyValue[2]['name'], $object->getProperty()[2]->getName()); $this->assertSame($propertyValue[2]['age'] ?? null, $object->getProperty()[2]->getAge()); $this->assertSame($propertyValue[2], $object->getProperty()[2]->getRawModelDataInput()); + $this->assertClassHasJsonPointer($object->getProperty()[2], '/properties/property/items/2'); + $this->assertPropertyHasJsonPointer( + $object->getProperty()[2], + 'name', + '/properties/property/items/2/properties/name', + ); } } diff --git a/tests/PropertyProcessor/PropertyProcessorFactoryTest.php b/tests/PropertyProcessor/PropertyProcessorFactoryTest.php deleted file mode 100644 index dde9a9a2..00000000 --- a/tests/PropertyProcessor/PropertyProcessorFactoryTest.php +++ /dev/null @@ -1,94 +0,0 @@ -getProcessor( - $type, - new PropertyMetaDataCollection(), - new SchemaProcessor( - new RecursiveDirectoryProvider(__DIR__), - '', - new GeneratorConfiguration(), - new RenderQueue(), - ), - new Schema('', '', '', new JsonSchema('', [])), - ); - - $this->assertInstanceOf($expectedClass, $propertyProcessor); - } - - /** - * Provide valid properties which must result in a PropertyProcessor - */ - public static function validPropertyProvider(): array - { - return [ - 'array' => ['array', ArrayProcessor::class], - 'boolean' => ['boolean', BooleanProcessor::class], - 'integer' => ['integer', IntegerProcessor::class], - 'null' => ['null', NullProcessor::class], - 'number' => ['number', NumberProcessor::class], - 'object' => ['object', ObjectProcessor::class], - 'string' => ['string', StringProcessor::class] - ]; - } - - /** - * @throws SchemaException - */ - public function testGetInvalidPropertyProcessorThrowsAnException(): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessage('Unsupported property type Hello'); - - $propertyProcessorFactory = new PropertyProcessorFactory(); - - $propertyProcessorFactory->getProcessor( - 'Hello', - new PropertyMetaDataCollection(), - new SchemaProcessor( - new RecursiveDirectoryProvider(__DIR__), - '', - new GeneratorConfiguration(), - new RenderQueue(), - ), - new Schema('', '', '', new JsonSchema('', [])), - ); - } -} diff --git a/tests/Schema/ComposedAllOfTest/ObjectLevelCompositionWithMinProperties.json b/tests/Schema/ComposedAllOfTest/ObjectLevelCompositionWithMinProperties.json new file mode 100644 index 00000000..12dae079 --- /dev/null +++ b/tests/Schema/ComposedAllOfTest/ObjectLevelCompositionWithMinProperties.json @@ -0,0 +1,21 @@ +{ + "minProperties": 1, + "allOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "age": { + "type": "integer" + } + } + } + ] +} diff --git a/tests/Schema/ComposedNotTest/ObjectLevelNot.json b/tests/Schema/ComposedNotTest/ObjectLevelNot.json new file mode 100644 index 00000000..4d19fbae --- /dev/null +++ b/tests/Schema/ComposedNotTest/ObjectLevelNot.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "not": { + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + } +} diff --git a/tests/Schema/DraftExtensibilityTest/StringWithCustomMin.json b/tests/Schema/DraftExtensibilityTest/StringWithCustomMin.json new file mode 100644 index 00000000..aeca3390 --- /dev/null +++ b/tests/Schema/DraftExtensibilityTest/StringWithCustomMin.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "value": { + "type": "string", + "customMin": 3 + } + } +} diff --git a/tests/Schema/DraftExtensibilityTest/StringWithFloatMinLength.json b/tests/Schema/DraftExtensibilityTest/StringWithFloatMinLength.json new file mode 100644 index 00000000..efc8257a --- /dev/null +++ b/tests/Schema/DraftExtensibilityTest/StringWithFloatMinLength.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 0.5 + } + } +} diff --git a/tests/Schema/FilterTest/IntegerPropertyCustomFilter.json b/tests/Schema/FilterTest/IntegerPropertyCustomFilter.json new file mode 100644 index 00000000..f2784ef0 --- /dev/null +++ b/tests/Schema/FilterTest/IntegerPropertyCustomFilter.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "integer", + "filter": "customFilter" + } + } +} diff --git a/tests/Schema/FilterTest/IntegerPropertyMixedFilter.json b/tests/Schema/FilterTest/IntegerPropertyMixedFilter.json new file mode 100644 index 00000000..a7fb46c3 --- /dev/null +++ b/tests/Schema/FilterTest/IntegerPropertyMixedFilter.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "integer", + "filter": "mixedFilter" + } + } +} diff --git a/tests/Schema/FilterTest/IntegerPropertyZeroOverlapFilter.json b/tests/Schema/FilterTest/IntegerPropertyZeroOverlapFilter.json new file mode 100644 index 00000000..c4969038 --- /dev/null +++ b/tests/Schema/FilterTest/IntegerPropertyZeroOverlapFilter.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "integer", + "filter": "numberFilter" + } + } +} diff --git a/tests/Schema/FilterTest/StringIntegerPropertyBinaryFilter.json b/tests/Schema/FilterTest/StringIntegerPropertyBinaryFilter.json new file mode 100644 index 00000000..fd3ccf7d --- /dev/null +++ b/tests/Schema/FilterTest/StringIntegerPropertyBinaryFilter.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "property": { + "type": [ + "string", + "integer" + ], + "filter": "binary" + } + } +} diff --git a/tests/Schema/FilterTest/StringIntegerPropertyCustomFilter.json b/tests/Schema/FilterTest/StringIntegerPropertyCustomFilter.json new file mode 100644 index 00000000..0b082e9c --- /dev/null +++ b/tests/Schema/FilterTest/StringIntegerPropertyCustomFilter.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "property": { + "type": [ + "string", + "integer" + ], + "filter": "customFilter" + } + } +} diff --git a/tests/Schema/FilterTest/StringIntegerPropertyFilterChain.json b/tests/Schema/FilterTest/StringIntegerPropertyFilterChain.json new file mode 100644 index 00000000..bc1d95f8 --- /dev/null +++ b/tests/Schema/FilterTest/StringIntegerPropertyFilterChain.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "created": { + "type": [ + "string", + "integer" + ], + "filter": [ + "trim", + "dateTime" + ] + } + } +} diff --git a/tests/Schema/FilterTest/StringNullPropertyCustomFilter.json b/tests/Schema/FilterTest/StringNullPropertyCustomFilter.json new file mode 100644 index 00000000..489095a9 --- /dev/null +++ b/tests/Schema/FilterTest/StringNullPropertyCustomFilter.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "property": { + "type": [ + "string", + "null" + ], + "filter": "customFilter" + } + } +} diff --git a/tests/Schema/FilterTest/StringNullPropertyStrOrNullFilter.json b/tests/Schema/FilterTest/StringNullPropertyStrOrNullFilter.json new file mode 100644 index 00000000..1f25c63c --- /dev/null +++ b/tests/Schema/FilterTest/StringNullPropertyStrOrNullFilter.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "property": { + "type": [ + "string", + "null" + ], + "filter": "strOrNull" + } + } +} diff --git a/tests/Schema/FilterTest/StringPropertyAcceptAllFilter.json b/tests/Schema/FilterTest/StringPropertyAcceptAllFilter.json new file mode 100644 index 00000000..9834c66e --- /dev/null +++ b/tests/Schema/FilterTest/StringPropertyAcceptAllFilter.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "string", + "filter": "acceptAll" + } + } +} diff --git a/tests/Schema/FilterTest/StringPropertyIntOrStringFilter.json b/tests/Schema/FilterTest/StringPropertyIntOrStringFilter.json new file mode 100644 index 00000000..496f7140 --- /dev/null +++ b/tests/Schema/FilterTest/StringPropertyIntOrStringFilter.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "string", + "filter": "intOrString" + } + } +} diff --git a/tests/Schema/FilterTest/StringPropertyMixedFilter.json b/tests/Schema/FilterTest/StringPropertyMixedFilter.json new file mode 100644 index 00000000..184bea16 --- /dev/null +++ b/tests/Schema/FilterTest/StringPropertyMixedFilter.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "string", + "filter": "mixedFilter" + } + } +} diff --git a/tests/Schema/FilterTest/StringPropertyNeverReturnFilter.json b/tests/Schema/FilterTest/StringPropertyNeverReturnFilter.json new file mode 100644 index 00000000..0de2e40f --- /dev/null +++ b/tests/Schema/FilterTest/StringPropertyNeverReturnFilter.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "string", + "filter": "neverReturn" + } + } +} diff --git a/tests/Schema/FilterTest/StringPropertyNoReturnTypeFilter.json b/tests/Schema/FilterTest/StringPropertyNoReturnTypeFilter.json new file mode 100644 index 00000000..a358cfce --- /dev/null +++ b/tests/Schema/FilterTest/StringPropertyNoReturnTypeFilter.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "string", + "filter": "noReturnType" + } + } +} diff --git a/tests/Schema/FilterTest/StringPropertyNoTypeHintFilter.json b/tests/Schema/FilterTest/StringPropertyNoTypeHintFilter.json new file mode 100644 index 00000000..d449e712 --- /dev/null +++ b/tests/Schema/FilterTest/StringPropertyNoTypeHintFilter.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "string", + "filter": "noTypeHint" + } + } +} diff --git a/tests/Schema/FilterTest/StringPropertyVoidReturnFilter.json b/tests/Schema/FilterTest/StringPropertyVoidReturnFilter.json new file mode 100644 index 00000000..aba91bde --- /dev/null +++ b/tests/Schema/FilterTest/StringPropertyVoidReturnFilter.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "string", + "filter": "voidReturn" + } + } +} diff --git a/tests/Schema/FilterTest/UntypedPropertyCustomFilter.json b/tests/Schema/FilterTest/UntypedPropertyCustomFilter.json new file mode 100644 index 00000000..fb66b359 --- /dev/null +++ b/tests/Schema/FilterTest/UntypedPropertyCustomFilter.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "property": { + "filter": "customFilter" + } + } +} diff --git a/tests/Schema/FilterTest/UntypedPropertyFilter.json b/tests/Schema/FilterTest/UntypedPropertyFilter.json new file mode 100644 index 00000000..28940733 --- /dev/null +++ b/tests/Schema/FilterTest/UntypedPropertyFilter.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "property": { + "filter": "trim" + } + } +} diff --git a/tests/Schema/FilterTest/UntypedPropertyFilterChain.json b/tests/Schema/FilterTest/UntypedPropertyFilterChain.json new file mode 100644 index 00000000..12955c87 --- /dev/null +++ b/tests/Schema/FilterTest/UntypedPropertyFilterChain.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": [ + "trim", + "dateTime" + ] + } + } +} diff --git a/tests/Schema/FilterTest/UntypedPropertyMixedFilter.json b/tests/Schema/FilterTest/UntypedPropertyMixedFilter.json new file mode 100644 index 00000000..5dff3e89 --- /dev/null +++ b/tests/Schema/FilterTest/UntypedPropertyMixedFilter.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "property": { + "filter": "mixedFilter" + } + } +} diff --git a/tests/Schema/Issues/103/NestedNonCamelCaseObjects.json b/tests/Schema/Issues/103/NestedNonCamelCaseObjects.json new file mode 100644 index 00000000..f746e612 --- /dev/null +++ b/tests/Schema/Issues/103/NestedNonCamelCaseObjects.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "properties": { + "product_id": { + "type": "string" + }, + "nested_object": { + "type": "object", + "properties": { + "inner_value": { + "type": "string" + } + }, + "required": [ + "inner_value" + ] + } + }, + "required": [ + "product_id" + ] +} diff --git a/tests/Schema/Issues/103/NonCamelCaseProperties.json b/tests/Schema/Issues/103/NonCamelCaseProperties.json new file mode 100644 index 00000000..43de394a --- /dev/null +++ b/tests/Schema/Issues/103/NonCamelCaseProperties.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "product_id": { + "type": "string" + }, + "my-thing": { + "type": "string" + }, + "my property": { + "type": "string" + } + }, + "required": [ + "product_id" + ] +} diff --git a/tests/Schema/Issues/103/OptionalNonCamelCaseProperty.json b/tests/Schema/Issues/103/OptionalNonCamelCaseProperty.json new file mode 100644 index 00000000..2e141715 --- /dev/null +++ b/tests/Schema/Issues/103/OptionalNonCamelCaseProperty.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "required_field": { + "type": "string" + }, + "product_id": { + "type": "string" + } + }, + "required": [ + "required_field" + ] +} diff --git a/tests/Schema/PatternPropertiesTest/PatternPropertiesObjectType.json b/tests/Schema/PatternPropertiesTest/PatternPropertiesObjectType.json new file mode 100644 index 00000000..f6998ad5 --- /dev/null +++ b/tests/Schema/PatternPropertiesTest/PatternPropertiesObjectType.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "patternProperties": { + "^person_": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "integer" + } + } + } + } +} diff --git a/tests/Schema/PhpAttributeTest/BasicSchema.json b/tests/Schema/PhpAttributeTest/BasicSchema.json index 33eb2d18..ae1c2c45 100644 --- a/tests/Schema/PhpAttributeTest/BasicSchema.json +++ b/tests/Schema/PhpAttributeTest/BasicSchema.json @@ -1,11 +1,27 @@ { "type": "object", + "deprecated": true, "properties": { - "name": { - "type": "string" + "my property": { + "type": "string", + "deprecated": false, + "readOnly": true + }, + "slash/property": { + "type": "string", + "deprecated": true, + "readOnly": false, + "writeOnly": false }, - "age": { - "type": "integer" + "tilde~property": { + "type": "string", + "writeOnly": true + }, + "123name": { + "type": "string" } - } + }, + "required": [ + "my property" + ] } diff --git a/tests/Schema/SingleFileProviderTest/Address.json b/tests/Schema/SingleFileProviderTest/Address.json new file mode 100644 index 00000000..c5306b71 --- /dev/null +++ b/tests/Schema/SingleFileProviderTest/Address.json @@ -0,0 +1,9 @@ +{ + "title": "SingleFileProviderAddress", + "type": "object", + "properties": { + "city": { + "type": "string" + } + } +} diff --git a/tests/Schema/SingleFileProviderTest/InvalidJSON.json b/tests/Schema/SingleFileProviderTest/InvalidJSON.json new file mode 100644 index 00000000..4ab10b4d --- /dev/null +++ b/tests/Schema/SingleFileProviderTest/InvalidJSON.json @@ -0,0 +1 @@ +this is not valid json diff --git a/tests/Schema/SingleFileProviderTest/Person.json b/tests/Schema/SingleFileProviderTest/Person.json new file mode 100644 index 00000000..444687bb --- /dev/null +++ b/tests/Schema/SingleFileProviderTest/Person.json @@ -0,0 +1,12 @@ +{ + "title": "SingleFileProviderPerson", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "integer" + } + } +} diff --git a/tests/Schema/SingleFileProviderTest/PersonWithAddress.json b/tests/Schema/SingleFileProviderTest/PersonWithAddress.json new file mode 100644 index 00000000..c23340e1 --- /dev/null +++ b/tests/Schema/SingleFileProviderTest/PersonWithAddress.json @@ -0,0 +1,12 @@ +{ + "title": "SingleFileProviderPersonWithAddress", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "address": { + "$ref": "Address.json" + } + } +} diff --git a/tests/SchemaProvider/SingleFileProviderTest.php b/tests/SchemaProvider/SingleFileProviderTest.php new file mode 100644 index 00000000..e8c9246a --- /dev/null +++ b/tests/SchemaProvider/SingleFileProviderTest.php @@ -0,0 +1,98 @@ +setCollectErrors(false))->setOutputEnabled(false); + + (new ModelGenerator($config))->generateModels( + new SingleFileProvider($this->getSchemaFilePath($file)), + MODEL_TEMP_PATH, + ); + } + + /** + * A valid single-file schema generates a usable PHP model class. + * Also verifies that getBaseDirectory() returns the directory containing the source file, + * since both use the same schema file and configuration. + */ + public function testSingleFileProviderGeneratesClass(): void + { + $filePath = $this->getSchemaFilePath('Person.json'); + $provider = new SingleFileProvider($filePath); + + // getBaseDirectory() must point to the directory that contains Person.json so that + // relative $ref paths in the same directory can be resolved correctly. + $this->assertSame(dirname(realpath($filePath)), $provider->getBaseDirectory()); + + (new ModelGenerator( + (new GeneratorConfiguration())->setCollectErrors(false)->setOutputEnabled(false), + ))->generateModels($provider, MODEL_TEMP_PATH); + + $person = new \SingleFileProviderPerson(['name' => 'Alice', 'age' => 30]); + $this->assertSame('Alice', $person->getName()); + $this->assertSame(30, $person->getAge()); + $this->assertNull((new \SingleFileProviderPerson([]))->getName()); + } + + /** + * 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 + { + // 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()); + } + + // 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()); + } + } + + /** + * 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 + * relative to the source file's directory. + */ + public function testExternalRefIsResolved(): void + { + $this->generateViaProvider('PersonWithAddress.json'); + + $person = new \SingleFileProviderPersonWithAddress([ + 'name' => 'Alice', + 'address' => ['city' => 'Berlin'], + ]); + + $this->assertSame('Alice', $person->getName()); + $this->assertNotNull($person->getAddress()); + $this->assertSame('Berlin', $person->getAddress()->getCity()); + } +}