diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0094988b..e3ae7e5a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -31,7 +31,9 @@ "Bash(composer show:*)", "Bash(php -r \":*)", "Bash(gh pr:*)", - "Bash(xargs wc:*)" + "Bash(xargs wc:*)", + "Bash(tee /tmp/phpunit-output.txt)", + "Read(//tmp/**)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index ce96adc2..0c76f16b 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.) @@ -131,6 +161,10 @@ property, duplicate property names with unresolvable type conflicts, and any oth that cannot produce a correct PHP model. Fail loudly at generation time so the developer sees the problem immediately rather than receiving silently incorrect generated code. +### Reading files + +Always use the dedicated `Read` tool to read file contents. Never use `sed`, `head`, `tail`, `cat`, or `awk` to read or extract portions of files. The `Read` tool supports `offset` and `limit` parameters for reading partial files when needed. + ### PHP import style Always add `use` imports for every class referenced in a file, including global PHP classes such as diff --git a/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/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..7cbee0f8 100644 --- a/docs/source/gettingStarted.rst +++ b/docs/source/gettingStarted.rst @@ -309,6 +309,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/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..fa221218 --- /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[] = $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..e4ff88d7 --- /dev/null +++ b/src/Draft/Modifier/DefaultValueModifier.php @@ -0,0 +1,59 @@ +getJson(); + + if (!array_key_exists('default', $json)) { + return; + } + + $default = $json['default']; + $types = isset($json['type']) ? (array) $json['type'] : []; + + if (empty($types)) { + $property->setDefaultValue($default); + return; + } + + foreach ($types as $jsonType) { + $phpType = TypeConverter::jsonSchemaToPhp($jsonType); + + // Allow integer literals as defaults for 'number' (float) properties + if ($phpType === 'float' && is_int($default)) { + $default = (float) $default; + } + + $typeCheckFn = 'is_' . $phpType; + if (function_exists($typeCheckFn) && $typeCheckFn($default)) { + $property->setDefaultValue($default); + return; + } + } + + throw new SchemaException( + sprintf( + 'Invalid type for default value of property %s in file %s', + $property->getName(), + $propertySchema->getFile(), + ), + ); + } +} diff --git a/src/Draft/Modifier/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..74164efe --- /dev/null +++ b/src/Draft/Modifier/ObjectType/ObjectModifier.php @@ -0,0 +1,76 @@ +getNestedSchema(); + if ($nestedSchema === null) { + return; + } + + if ( + $nestedSchema->getClassPath() !== $schema->getClassPath() || + $nestedSchema->getClassName() !== $schema->getClassName() + ) { + $schema->addUsedClass( + join( + '\\', + array_filter([ + $schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(), + $nestedSchema->getClassPath(), + $nestedSchema->getClassName(), + ]), + ) + ); + + $schema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($nestedSchema)); + } + + $property + ->addDecorator( + new ObjectInstantiationDecorator( + $nestedSchema->getClassName(), + $schemaProcessor->getGeneratorConfiguration(), + ) + ) + ->setType(new PropertyType($nestedSchema->getClassName())); + + $property->addValidator(new InstanceOfValidator($property), 3); + } +} diff --git a/src/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/GeneratorConfiguration.php b/src/Model/GeneratorConfiguration.php index 669cd534..9ab7411c 100644 --- a/src/Model/GeneratorConfiguration.php +++ b/src/Model/GeneratorConfiguration.php @@ -5,6 +5,10 @@ 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; @@ -14,7 +18,6 @@ use PHPModelGenerator\PropertyProcessor\Filter\TrimFilter; use PHPModelGenerator\Utils\ClassNameGenerator; use PHPModelGenerator\Utils\ClassNameGeneratorInterface; -use PHPModelGenerator\Exception\ErrorRegistryException; /** * Class GeneratorConfiguration @@ -42,6 +45,9 @@ class GeneratorConfiguration /** @var bool */ protected $serialization = false; + /** @var DraftInterface | DraftFactoryInterface */ + protected $draft; + /** @var ClassNameGeneratorInterface */ protected $classNameGenerator; @@ -55,6 +61,7 @@ class GeneratorConfiguration */ public function __construct() { + $this->draft = new AutoDetectionDraft(); $this->classNameGenerator = new ClassNameGenerator(); // add all built-in filter and format validators @@ -244,6 +251,18 @@ public function setErrorRegistryClass(string $errorRegistryClass): self return $this; } + public function getDraft(): DraftInterface | DraftFactoryInterface + { + return $this->draft; + } + + public function setDraft(DraftInterface | DraftFactoryInterface $draft): self + { + $this->draft = $draft; + + return $this; + } + public function isImplicitNullAllowed(): bool { return $this->allowImplicitNull; diff --git a/src/Model/Schema.php b/src/Model/Schema.php index ee837dee..ebb1b875 100644 --- a/src/Model/Schema.php +++ b/src/Model/Schema.php @@ -13,7 +13,7 @@ use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; use PHPModelGenerator\Model\Validator\PropertyValidatorInterface; use PHPModelGenerator\Model\Validator\SchemaDependencyValidator; -use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor; +use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookInterface; use PHPModelGenerator\Utils\PropertyMerger; @@ -170,7 +170,7 @@ public function addProperty(PropertyInterface $property, ?string $compositionPro $this->propertyMerger->merge( $this->properties[$property->getName()], $property, - is_a($compositionProcessor, AllOfProcessor::class, true), + is_a($compositionProcessor, AllOfValidatorFactory::class, true), ); return $this; diff --git a/src/Model/SchemaDefinition/SchemaDefinition.php b/src/Model/SchemaDefinition/SchemaDefinition.php index 8f5dc7b6..c8676587 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; /** @@ -52,7 +50,8 @@ public function getSchema(): Schema public function resolveReference( string $propertyName, array $path, - PropertyMetaDataCollection $propertyMetaDataCollection, + bool $required, + ?array $dependencies = null, ): PropertyInterface { $jsonSchema = $this->source->getJson(); $originalPath = $path; @@ -67,7 +66,7 @@ public function resolveReference( // if the properties point to the same definition and share identical metadata the generated property can be // recycled. Otherwise, a new property must be generated as diverging metadata lead to different validators. - $key = implode('-', [...$originalPath, $propertyMetaDataCollection->getHash($propertyName)]); + $key = implode('-', [...$originalPath, $required ? '1' : '0', md5(json_encode($dependencies))]); if (!$this->resolvedPaths->offsetExists($key)) { // create a dummy entry for the path first. If the path is used recursive the recursive usages will point @@ -75,14 +74,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), + $required, ); + $this->resolvedPaths->offsetSet($key, $property); /** @var PropertyProxy $proxy */ diff --git a/src/Model/Validator/AdditionalPropertiesValidator.php b/src/Model/Validator/AdditionalPropertiesValidator.php index c9a0938d..66e1816f 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]), + 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..94a1ee22 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), + 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..5fb0eefd --- /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->withJson($json[$this->key]), + ); + + $property->addValidator( + new PropertyTemplateValidator( + $property, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ArrayContains.phptpl', + [ + 'property' => $nestedProperty, + 'schema' => $schema, + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + ], + ContainsException::class, + ), + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php new file mode 100644 index 00000000..031ea2e4 --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php @@ -0,0 +1,120 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $itemsSchema = $json[$this->key]; + + // tuple validation: items is a sequential array + if ( + is_array($itemsSchema) && + array_keys($itemsSchema) === range(0, count($itemsSchema) - 1) + ) { + $this->addTupleValidator($schemaProcessor, $schema, $property, $propertySchema, $itemsSchema); + + return; + } + + $property->addValidator( + new ArrayItemValidator( + $schemaProcessor, + $schema, + $propertySchema->withJson($itemsSchema), + $property, + ), + ); + } + + /** + * @throws SchemaException + */ + private function addTupleValidator( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + array $itemsSchema, + ): void { + $json = $propertySchema->getJson(); + + if (isset($json['additionalItems']) && $json['additionalItems'] !== true) { + $this->addAdditionalItemsValidator($schemaProcessor, $schema, $property, $propertySchema, $itemsSchema); + } + + $property->addValidator( + new ArrayTupleValidator( + $schemaProcessor, + $schema, + $propertySchema->withJson($itemsSchema), + $property->getName(), + ), + ); + } + + /** + * @throws SchemaException + */ + private function addAdditionalItemsValidator( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + array $itemsSchema, + ): void { + $json = $propertySchema->getJson(); + + if (!is_bool($json['additionalItems'])) { + $property->addValidator( + new AdditionalItemsValidator( + $schemaProcessor, + $schema, + $propertySchema, + $property->getName(), + ), + ); + + return; + } + + $expectedAmount = count($itemsSchema); + + $property->addValidator( + new PropertyValidator( + $property, + '($amount = count($value)) > ' . $expectedAmount, + AdditionalTupleItemsException::class, + [$expectedAmount, '&$amount'], + ), + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/MaxItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/MaxItemsValidatorFactory.php new file mode 100644 index 00000000..4d7b940e --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/MaxItemsValidatorFactory.php @@ -0,0 +1,29 @@ += 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + "is_array(\$value) && count(\$value) > $value", + MaxItemsException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/MinItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/MinItemsValidatorFactory.php new file mode 100644 index 00000000..188ae00b --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/MinItemsValidatorFactory.php @@ -0,0 +1,29 @@ += 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + "is_array(\$value) && count(\$value) < $value", + MinItemsException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Arrays/UniqueItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/UniqueItemsValidatorFactory.php new file mode 100644 index 00000000..7a955751 --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/UniqueItemsValidatorFactory.php @@ -0,0 +1,38 @@ +getJson(); + + if (!isset($json[$this->key]) || $json[$this->key] !== true) { + return; + } + + $property->addValidator( + new PropertyTemplateValidator( + $property, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ArrayUnique.phptpl', + [], + UniqueItemsException::class, + ), + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php new file mode 100644 index 00000000..643199c0 --- /dev/null +++ b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php @@ -0,0 +1,221 @@ +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 $compositionElement) { + $compositionSchema = $propertySchema->getJson()['propertySchema']->withJson($compositionElement); + + $compositionProperty = new CompositionPropertyDecorator( + $property->getName(), + $compositionSchema, + $propertyFactory->create( + $schemaProcessor, + $schema, + $property->getName(), + $compositionSchema, + $property->isRequired(), + ), + ); + + $compositionProperty->onResolve(function () use ($compositionProperty, $property, $merged): void { + $compositionProperty->filterValidators( + static fn(Validator $validator): bool => + !is_a($validator->getValidator(), RequiredPropertyValidator::class) && + !is_a($validator->getValidator(), ComposedPropertyValidator::class), + ); + + if (!($merged && $compositionProperty->getNestedSchema())) { + $property->addTypeHintDecorator(new CompositionTypeHintDecorator($compositionProperty)); + } + }); + + $compositionProperties[] = $compositionProperty; + } + + return $compositionProperties; + } + + /** + * Inherit a parent-level type into composition branches that declare no type. + */ + protected function inheritPropertyType(JsonSchema $propertySchema): JsonSchema + { + $json = $propertySchema->getJson(); + + if (!isset($json['type'])) { + return $propertySchema; + } + + if ($json['type'] === 'base') { + $json['type'] = 'object'; + } + + switch ($this->key) { + case 'not': + if (!isset($json[$this->key]['type'])) { + $json[$this->key]['type'] = $json['type']; + } + break; + case 'if': + return $this->inheritIfPropertyType($propertySchema->withJson($json)); + default: + foreach ($json[$this->key] as &$composedElement) { + if (!isset($composedElement['type'])) { + $composedElement['type'] = $json['type']; + } + } + } + + return $propertySchema->withJson($json); + } + + /** + * Inherit the parent type into all branches of an if/then/else composition. + */ + protected function inheritIfPropertyType(JsonSchema $propertySchema): JsonSchema + { + $json = $propertySchema->getJson(); + + foreach (['if', 'then', 'else'] as $keyword) { + if (!isset($json[$keyword])) { + continue; + } + + if (!isset($json[$keyword]['type'])) { + $json[$keyword]['type'] = $json['type']; + } + } + + return $propertySchema->withJson($json); + } + + /** + * After all composition branches resolve, attempt to widen the parent property's type + * to cover all branch types. Skips for branches with nested schemas. + * + * @param bool $isAllOf Whether allOf semantics apply (affects nullable detection). + * @param CompositionPropertyDecorator[] $compositionProperties + */ + protected function transferPropertyType( + PropertyInterface $property, + array $compositionProperties, + bool $isAllOf, + ): void { + foreach ($compositionProperties as $compositionProperty) { + if ($compositionProperty->getNestedSchema() !== null) { + return; + } + } + + $allNames = array_merge(...array_map( + static fn(CompositionPropertyDecorator $p): array => + $p->getType() ? $p->getType()->getNames() : [], + $compositionProperties, + )); + + $hasBranchWithNoType = array_filter( + $compositionProperties, + static fn(CompositionPropertyDecorator $p): bool => $p->getType() === null, + ) !== []; + + $hasBranchWithRequiredProperty = array_filter( + $compositionProperties, + static fn(CompositionPropertyDecorator $p): bool => $p->isRequired(), + ) !== []; + + $hasBranchWithOptionalProperty = $isAllOf + ? !$hasBranchWithRequiredProperty + : array_filter( + $compositionProperties, + static fn(CompositionPropertyDecorator $p): bool => !$p->isRequired(), + ) !== []; + + $hasNull = in_array('null', $allNames, true); + $nonNullNames = array_values(array_filter( + array_unique($allNames), + fn(string $t): bool => $t !== 'null', + )); + + if (!$nonNullNames) { + return; + } + + $nullable = ($hasNull || $hasBranchWithNoType || $hasBranchWithOptionalProperty) ? true : null; + + $property->setType(new PropertyType($nonNullNames, $nullable)); + } +} diff --git a/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php new file mode 100644 index 00000000..9b76ec2e --- /dev/null +++ b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php @@ -0,0 +1,104 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); + $propertySchema = $this->inheritPropertyType($propertySchema); + + $wrappedSchema = $propertySchema->withJson([ + 'type' => $this->key, + 'propertySchema' => $propertySchema, + 'onlyForDefinedValues' => false, + ]); + + $compositionProperties = $this->getCompositionProperties( + $schemaProcessor, + $schema, + $property, + $wrappedSchema, + true, + ); + + $resolvedCompositions = 0; + $mergedProperty = null; + foreach ($compositionProperties as $compositionProperty) { + $compositionProperty->onResolve( + function () use ( + &$resolvedCompositions, + &$mergedProperty, + $property, + $compositionProperties, + $wrappedSchema, + $schemaProcessor, + $schema, + ): void { + if (++$resolvedCompositions === count($compositionProperties)) { + $this->transferPropertyType($property, $compositionProperties, true); + + $mergedProperty = !($property instanceof BaseProperty) + ? $schemaProcessor->createMergedProperty( + $schema, + $property, + $compositionProperties, + $wrappedSchema, + ) + : null; + } + }, + ); + } + + $availableAmount = count($compositionProperties); + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + AllOfException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => $availableAmount, + 'composedValueValidation' => "\$succeededCompositionElements === $availableAmount", + 'postPropose' => true, + 'mergedProperty' => &$mergedProperty, + 'onlyForDefinedValues' => false, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php new file mode 100644 index 00000000..33c25085 --- /dev/null +++ b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php @@ -0,0 +1,108 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); + $propertySchema = $this->inheritPropertyType($propertySchema); + + $onlyForDefinedValues = !($property instanceof BaseProperty) + && (!$property->isRequired() + && $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()); + + $wrappedSchema = $propertySchema->withJson([ + 'type' => $this->key, + 'propertySchema' => $propertySchema, + 'onlyForDefinedValues' => $onlyForDefinedValues, + ]); + + $compositionProperties = $this->getCompositionProperties( + $schemaProcessor, + $schema, + $property, + $wrappedSchema, + true, + ); + + $resolvedCompositions = 0; + $mergedProperty = null; + foreach ($compositionProperties as $compositionProperty) { + $compositionProperty->onResolve( + function () use ( + &$resolvedCompositions, + &$mergedProperty, + $property, + $compositionProperties, + $wrappedSchema, + $schemaProcessor, + $schema, + ): void { + if (++$resolvedCompositions === count($compositionProperties)) { + $this->transferPropertyType($property, $compositionProperties, false); + + $mergedProperty = !($property instanceof BaseProperty) + ? $schemaProcessor->createMergedProperty( + $schema, + $property, + $compositionProperties, + $wrappedSchema, + ) + : null; + } + }, + ); + } + + $availableAmount = count($compositionProperties); + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + AnyOfException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => $availableAmount, + 'composedValueValidation' => '$succeededCompositionElements > 0', + 'postPropose' => true, + 'mergedProperty' => &$mergedProperty, + 'onlyForDefinedValues' => $onlyForDefinedValues, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/ComposedPropertiesValidatorFactoryInterface.php b/src/Model/Validator/Factory/Composition/ComposedPropertiesValidatorFactoryInterface.php new file mode 100644 index 00000000..fb29fc5a --- /dev/null +++ b/src/Model/Validator/Factory/Composition/ComposedPropertiesValidatorFactoryInterface.php @@ -0,0 +1,16 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + $json = $propertySchema->getJson(); + + if (!isset($json['then']) && !isset($json['else'])) { + throw new SchemaException( + sprintf( + 'Incomplete conditional composition for property %s in file %s', + $property->getName(), + $property->getJsonSchema()->getFile(), + ), + ); + } + + $propertySchema = $this->inheritPropertyType($propertySchema); + $json = $propertySchema->getJson(); + + $propertyFactory = new PropertyFactory(); + + $onlyForDefinedValues = !($property instanceof BaseProperty) + && (!$property->isRequired() + && $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()); + + /** @var array $properties */ + $properties = []; + + foreach (['if', 'then', 'else'] as $keyword) { + if (!isset($json[$keyword])) { + $properties[$keyword] = null; + continue; + } + + $compositionSchema = $propertySchema->withJson($json[$keyword]); + + $compositionProperty = new CompositionPropertyDecorator( + $property->getName(), + $compositionSchema, + $propertyFactory->create( + $schemaProcessor, + $schema, + $property->getName(), + $compositionSchema, + $property->isRequired(), + ), + ); + + $compositionProperty->onResolve(static function () use ($compositionProperty): void { + $compositionProperty->filterValidators( + static fn(Validator $validator): bool => + !is_a($validator->getValidator(), RequiredPropertyValidator::class) && + !is_a($validator->getValidator(), ComposedPropertyValidator::class), + ); + }); + + $properties[$keyword] = $compositionProperty; + } + + $property->addValidator( + new ConditionalPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + array_values(array_filter($properties)), + array_values(array_filter([$properties['then'], $properties['else']])), + [ + 'ifProperty' => $properties['if'], + 'thenProperty' => $properties['then'], + 'elseProperty' => $properties['else'], + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'onlyForDefinedValues' => $onlyForDefinedValues, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/NotValidatorFactory.php b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php new file mode 100644 index 00000000..2f240535 --- /dev/null +++ b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php @@ -0,0 +1,82 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + // Inherit the parent type into the not branch before wrapping in array. + // inheritPropertyType for 'not' treats $json['not'] as a single schema object, + // so it must run before we wrap it in an array for iteration. + $propertySchema = $this->inheritPropertyType($propertySchema); + $json = $propertySchema->getJson(); + + // Wrap the single 'not' schema in an array so getCompositionProperties can iterate it. + $json[$this->key] = [$json[$this->key]]; + $wrappedOuter = $propertySchema->withJson($json); + + // Force required=true so strict null checks apply inside the not branch. + $property->setRequired(true); + + $wrappedSchema = $wrappedOuter->withJson([ + 'type' => $this->key, + 'propertySchema' => $wrappedOuter, + 'onlyForDefinedValues' => false, + ]); + + $compositionProperties = $this->getCompositionProperties( + $schemaProcessor, + $schema, + $property, + $wrappedSchema, + false, + ); + + $availableAmount = count($compositionProperties); + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + NotException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => $availableAmount, + 'composedValueValidation' => '$succeededCompositionElements === 0', + 'postPropose' => false, + 'mergedProperty' => null, + 'onlyForDefinedValues' => false, + ], + ), + 100, + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php new file mode 100644 index 00000000..8921981a --- /dev/null +++ b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php @@ -0,0 +1,90 @@ +getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) { + return; + } + + $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); + $propertySchema = $this->inheritPropertyType($propertySchema); + + $onlyForDefinedValues = !($property instanceof BaseProperty) + && (!$property->isRequired() + && $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()); + + $wrappedSchema = $propertySchema->withJson([ + 'type' => $this->key, + 'propertySchema' => $propertySchema, + 'onlyForDefinedValues' => $onlyForDefinedValues, + ]); + + $compositionProperties = $this->getCompositionProperties( + $schemaProcessor, + $schema, + $property, + $wrappedSchema, + false, + ); + + $resolvedCompositions = 0; + foreach ($compositionProperties as $compositionProperty) { + $compositionProperty->onResolve( + function () use (&$resolvedCompositions, $property, $compositionProperties): void { + if (++$resolvedCompositions === count($compositionProperties)) { + $this->transferPropertyType($property, $compositionProperties, false); + } + }, + ); + } + + $availableAmount = count($compositionProperties); + + $property->addValidator( + new ComposedPropertyValidator( + $schemaProcessor->getGeneratorConfiguration(), + $property, + $compositionProperties, + static::class, + OneOfException::class, + [ + 'compositionProperties' => $compositionProperties, + 'schema' => $schema, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + 'availableAmount' => $availableAmount, + 'composedValueValidation' => '$succeededCompositionElements === 1', + 'postPropose' => true, + 'mergedProperty' => null, + 'onlyForDefinedValues' => $onlyForDefinedValues, + ], + ), + 100, + ); + } +} diff --git a/src/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..e92ef12a --- /dev/null +++ b/src/Model/Validator/Factory/Object/MaxPropertiesValidatorFactory.php @@ -0,0 +1,39 @@ +_rawModelDataInput), + array_keys($modelData), + ) + ), + )'; + + protected function isValueValid(mixed $value): bool + { + return is_int($value) && $value >= 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + sprintf('%s > %d', self::COUNT_PROPERTIES, $value), + MaxPropertiesException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Object/MinPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/MinPropertiesValidatorFactory.php new file mode 100644 index 00000000..5d90d6e2 --- /dev/null +++ b/src/Model/Validator/Factory/Object/MinPropertiesValidatorFactory.php @@ -0,0 +1,40 @@ +_rawModelDataInput), + array_keys($modelData), + ) + ), + )'; + + protected function isValueValid(mixed $value): bool + { + return is_int($value) && $value >= 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + sprintf('%s < %d', self::COUNT_PROPERTIES, $value), + MinPropertiesException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php new file mode 100644 index 00000000..026aeeab --- /dev/null +++ b/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php @@ -0,0 +1,51 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + foreach ($json[$this->key] as $pattern => $patternSchema) { + $escapedPattern = addcslashes((string) $pattern, '/'); + + if (@preg_match("/$escapedPattern/", '') === false) { + throw new SchemaException( + "Invalid pattern '$pattern' for pattern property in file {$propertySchema->getFile()}", + ); + } + + $schema->addBaseValidator( + new PatternPropertiesValidator( + $schemaProcessor, + $schema, + $pattern, + $propertySchema->withJson($patternSchema), + ) + ); + } + } +} diff --git a/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php new file mode 100644 index 00000000..e6041c65 --- /dev/null +++ b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php @@ -0,0 +1,139 @@ +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])), + [], + ); + + foreach ($json[$this->key] as $propertyName => $propertyStructure) { + if ($propertyStructure === false) { + if (in_array($propertyName, $json['required'] ?? [], true)) { + throw new SchemaException( + sprintf( + "Property '%s' is denied (schema false) but also listed as required in file %s", + $propertyName, + $propertySchema->getFile(), + ), + ); + } + + $schema->addBaseValidator( + new PropertyValidator( + new Property($propertyName, null, $propertySchema->withJson([])), + "array_key_exists('" . addslashes($propertyName) . "', \$modelData)", + DeniedPropertyException::class, + ) + ); + continue; + } + + $required = in_array($propertyName, $json['required'] ?? [], true); + $dependencies = $json['dependencies'][$propertyName] ?? null; + $nestedProperty = $propertyFactory->create( + $schemaProcessor, + $schema, + (string) $propertyName, + $propertySchema->withJson( + $dependencies !== null + ? $propertyStructure + ['_dependencies' => $dependencies] + : $propertyStructure, + ), + $required, + ); + + if ($dependencies !== null) { + $this->addDependencyValidator($nestedProperty, $dependencies, $schemaProcessor, $schema); + } + + $schema->addProperty($nestedProperty); + } + } + + /** + * @throws SchemaException + */ + private function addDependencyValidator( + PropertyInterface $property, + array $dependencies, + SchemaProcessor $schemaProcessor, + Schema $schema, + ): void { + $propertyDependency = true; + + array_walk( + $dependencies, + static function ($dependency, $index) use (&$propertyDependency): void { + $propertyDependency = $propertyDependency && is_int($index) && is_string($dependency); + }, + ); + + if ($propertyDependency) { + $property->addValidator(new PropertyDependencyValidator($property, $dependencies)); + + return; + } + + if (!isset($dependencies['type'])) { + $dependencies['type'] = 'object'; + } + + $dependencySchema = $schemaProcessor->processSchema( + new JsonSchema($schema->getJsonSchema()->getFile(), $dependencies), + $schema->getClassPath(), + "{$schema->getClassName()}_{$property->getName()}_Dependency", + $schema->getSchemaDictionary(), + ); + + $property->addValidator(new SchemaDependencyValidator($schemaProcessor, $property, $dependencySchema)); + $schema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($dependencySchema)); + + $this->transferDependentPropertiesToBaseSchema($dependencySchema, $schema); + } + + private function transferDependentPropertiesToBaseSchema(Schema $dependencySchema, Schema $schema): void + { + foreach ($dependencySchema->getProperties() as $dependencyProperty) { + $schema->addProperty( + (clone $dependencyProperty) + ->setRequired(false) + ->setType(null) + ->filterValidators(static fn(): bool => false), + ); + } + } +} diff --git a/src/Model/Validator/Factory/Object/PropertyNamesValidatorFactory.php b/src/Model/Validator/Factory/Object/PropertyNamesValidatorFactory.php new file mode 100644 index 00000000..dd8db8e2 --- /dev/null +++ b/src/Model/Validator/Factory/Object/PropertyNamesValidatorFactory.php @@ -0,0 +1,40 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $schema->addBaseValidator( + new PropertyNamesValidator( + $schemaProcessor, + $schema, + $propertySchema->withJson($json[$this->key]), + ) + ); + } +} diff --git a/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php b/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php new file mode 100644 index 00000000..5f448964 --- /dev/null +++ b/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php @@ -0,0 +1,28 @@ +hasValidValue($property, $propertySchema)) { + return; + } + + $schema->addBaseValidator( + $this->getValidator($property, $propertySchema->getJson()[$this->key]), + ); + } +} diff --git a/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php b/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php new file mode 100644 index 00000000..29a71d79 --- /dev/null +++ b/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php @@ -0,0 +1,57 @@ +hasValidValue($property, $propertySchema)) { + return; + } + + $property->addValidator( + $this->getValidator($property, $propertySchema->getJson()[$this->key]), + ); + } + + protected function hasValidValue(PropertyInterface $property, JsonSchema $propertySchema): bool + { + $json = $propertySchema->getJson(); + + if (!isset($json[$this->key])) { + return false; + } + + if (!$this->isValueValid($json[$this->key])) { + throw new SchemaException( + sprintf( + "Invalid %s %s for property '%s' in file %s", + $this->key, + str_replace("\n", '', var_export($json[$this->key], true)), + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + return true; + } + + abstract protected function isValueValid(mixed $value): bool; + + abstract protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface; +} diff --git a/src/Model/Validator/Factory/String/FormatValidatorFactory.php b/src/Model/Validator/Factory/String/FormatValidatorFactory.php new file mode 100644 index 00000000..725066fd --- /dev/null +++ b/src/Model/Validator/Factory/String/FormatValidatorFactory.php @@ -0,0 +1,54 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $format = $json[$this->key]; + $formatValidator = $schemaProcessor->getGeneratorConfiguration()->getFormat($format); + + if (!$formatValidator) { + throw new SchemaException( + sprintf( + 'Unsupported format %s for property %s in file %s', + $format, + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + $property->addValidator( + new FormatValidator( + $property, + $formatValidator, + [$format], + ), + ); + } +} diff --git a/src/Model/Validator/Factory/String/MaxLengthValidatorFactory.php b/src/Model/Validator/Factory/String/MaxLengthValidatorFactory.php new file mode 100644 index 00000000..4caf0ba0 --- /dev/null +++ b/src/Model/Validator/Factory/String/MaxLengthValidatorFactory.php @@ -0,0 +1,23 @@ + $value", + MaxLengthException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/String/MinLengthPropertyValidatorFactory.php b/src/Model/Validator/Factory/String/MinLengthPropertyValidatorFactory.php new file mode 100644 index 00000000..98e2d54b --- /dev/null +++ b/src/Model/Validator/Factory/String/MinLengthPropertyValidatorFactory.php @@ -0,0 +1,29 @@ += 0; + } + + protected function getValidator(PropertyInterface $property, mixed $value): PropertyValidatorInterface + { + return new PropertyValidator( + $property, + "is_string(\$value) && mb_strlen(\$value) < $value", + MinLengthException::class, + [$value], + ); + } +} diff --git a/src/Model/Validator/Factory/String/PatternPropertyValidatorFactory.php b/src/Model/Validator/Factory/String/PatternPropertyValidatorFactory.php new file mode 100644 index 00000000..e1fa3b6f --- /dev/null +++ b/src/Model/Validator/Factory/String/PatternPropertyValidatorFactory.php @@ -0,0 +1,58 @@ +getJson(); + + if (!isset($json[$this->key])) { + return; + } + + $pattern = (string) $json[$this->key]; + $escapedPattern = addcslashes($pattern, '/'); + + if (@preg_match("/$escapedPattern/", '') === false) { + throw new SchemaException( + sprintf( + "Invalid pattern '%s' for property '%s' in file %s", + $pattern, + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + $encodedPattern = base64_encode("/$escapedPattern/"); + + $property->addValidator( + new PropertyValidator( + $property, + "is_string(\$value) && !preg_match(base64_decode('$encodedPattern'), \$value)", + PatternException::class, + [$pattern], + ), + ); + } +} diff --git a/src/Model/Validator/FilterValidator.php b/src/Model/Validator/FilterValidator.php index 23b6fe65..6959871a 100644 --- a/src/Model/Validator/FilterValidator.php +++ b/src/Model/Validator/FilterValidator.php @@ -121,24 +121,25 @@ public function addTransformedCheck(TransformingFilterInterface $filter, Propert */ private function validateFilterCompatibilityWithBaseType(FilterInterface $filter, PropertyInterface $property): void { - if (empty($filter->getAcceptedTypes()) || !$property->getType()) { + if (empty($filter->getAcceptedTypes()) || (!$property->getType() && !$property->getNestedSchema())) { return; } - $typeNames = $property->getType()->getNames(); - if ( - ( - !empty($typeNames) && - !empty(array_diff($typeNames, $this->mapDataTypes($filter->getAcceptedTypes()))) - ) || ( - $property->getType()->isNullable() && !in_array('null', $filter->getAcceptedTypes()) - ) - ) { + $typeNames = $property->getNestedSchema() !== null ? ['object'] : $property->getType()->getNames(); + $incompatibleTypes = !empty($typeNames) + ? array_diff($typeNames, $this->mapDataTypes($filter->getAcceptedTypes())) + : []; + + if ($property->getType()?->isNullable() && !in_array('null', $filter->getAcceptedTypes())) { + $incompatibleTypes[] = 'null'; + } + + if (!empty($incompatibleTypes)) { throw new SchemaException( sprintf( 'Filter %s is not compatible with property type %s for property %s in file %s', $filter->getToken(), - implode('|', $typeNames), + implode('|', array_merge($typeNames, $property->getType()?->isNullable() ? ['null'] : [])), $property->getName(), $property->getJsonSchema()->getFile(), ) diff --git a/src/Model/Validator/PassThroughTypeCheckValidator.php b/src/Model/Validator/PassThroughTypeCheckValidator.php index 4f4655d3..0676e764 100644 --- a/src/Model/Validator/PassThroughTypeCheckValidator.php +++ b/src/Model/Validator/PassThroughTypeCheckValidator.php @@ -24,7 +24,7 @@ class PassThroughTypeCheckValidator extends PropertyValidator implements TypeChe public function __construct( ReflectionType $passThroughType, PropertyInterface $property, - TypeCheckValidator $typeCheckValidator, + TypeCheckValidator|MultiTypeCheckValidator $typeCheckValidator, ) { $this->types = array_merge($typeCheckValidator->getTypes(), [$passThroughType->getName()]); diff --git a/src/Model/Validator/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..b730141d 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,21 @@ public function __construct( ) { $this->isResolved = true; - $processor = array_key_exists('const', $propertiesNames->getJson()) - ? ConstProcessor::class - : StringProcessor::class; - - if ($processor === ConstProcessor::class && gettype($propertiesNames->getJson()['const']) !== 'string') { + if ( + array_key_exists('const', $propertiesNames->getJson()) && + gettype($propertiesNames->getJson()['const']) !== 'string' + ) { throw new SchemaException("Invalid const property name in file {$propertiesNames->getFile()}"); } - $nameValidationProperty = (new $processor(new PropertyMetaDataCollection(), $schemaProcessor, $schema)) - ->process('property name', $propertiesNames) + // Property names are always strings; ensure the schema declares the type so that + // string-specific validators (minLength, maxLength, pattern) are applied. + $propertiesNamesAsString = array_key_exists('type', $propertiesNames->getJson()) + ? $propertiesNames + : $propertiesNames->withJson(['type' => 'string'] + $propertiesNames->getJson()); + + $nameValidationProperty = (new PropertyFactory()) + ->create($schemaProcessor, $schema, 'property name', $propertiesNamesAsString) // the property name validator doesn't need type checks or required checks so simply filter them out ->filterValidators(static fn(Validator $validator): bool => !is_a($validator->getValidator(), RequiredPropertyValidator::class) && diff --git a/src/PropertyProcessor/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/FilterProcessor.php b/src/PropertyProcessor/Filter/FilterProcessor.php index 53fdf81f..dc5d985a 100644 --- a/src/PropertyProcessor/Filter/FilterProcessor.php +++ b/src/PropertyProcessor/Filter/FilterProcessor.php @@ -15,6 +15,7 @@ use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Validator\EnumValidator; use PHPModelGenerator\Model\Validator\FilterValidator; +use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator; use PHPModelGenerator\Model\Validator\PassThroughTypeCheckValidator; use PHPModelGenerator\Model\Validator\PropertyValidator; use PHPModelGenerator\Model\Validator\ReflectionTypeCheckValidator; @@ -210,7 +211,10 @@ private function extendTypeCheckValidatorToAllowTransformedValue( $typeCheckValidator = null; $property->filterValidators(static function (Validator $validator) use (&$typeCheckValidator): bool { - if (is_a($validator->getValidator(), TypeCheckValidator::class)) { + if ( + is_a($validator->getValidator(), TypeCheckValidator::class) || + is_a($validator->getValidator(), MultiTypeCheckValidator::class) + ) { $typeCheckValidator = $validator->getValidator(); return false; } @@ -218,7 +222,10 @@ private function extendTypeCheckValidatorToAllowTransformedValue( return true; }); - if ($typeCheckValidator instanceof TypeCheckValidator) { + if ( + $typeCheckValidator instanceof TypeCheckValidator + || $typeCheckValidator instanceof MultiTypeCheckValidator + ) { // add a combined validator which checks for the transformed value or the original type of the property as a // replacement for the removed TypeCheckValidator $property->addValidator( diff --git a/src/PropertyProcessor/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..22b6fbbd 100644 --- a/src/PropertyProcessor/PropertyFactory.php +++ b/src/PropertyProcessor/PropertyFactory.php @@ -4,11 +4,26 @@ namespace PHPModelGenerator\PropertyProcessor; +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\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 +32,548 @@ */ 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); + } + + $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); + } + + return $property; + } + + /** + * Resolve a $ref reference by looking it up in the definition dictionary. + * + * @throws SchemaException + */ + private function processReference( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + bool $required, + ): PropertyInterface { + $path = []; + $reference = $propertySchema->getJson()['$ref']; + $dictionary = $schema->getSchemaDictionary(); + + try { + $definition = $dictionary->getDefinition($reference, $schemaProcessor, $path); + + if ($definition) { + $definitionSchema = $definition->getSchema(); + + if ( + $schema->getClassPath() !== $definitionSchema->getClassPath() || + $schema->getClassName() !== $definitionSchema->getClassName() || + ( + $schema->getClassName() === 'ExternalSchema' && + $definitionSchema->getClassName() === 'ExternalSchema' + ) + ) { + $schema->addNamespaceTransferDecorator( + new SchemaNamespaceTransferDecorator($definitionSchema), + ); + + if ($definitionSchema->getClassName() !== 'ExternalSchema') { + $schema->addUsedClass(join('\\', array_filter([ + $schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(), + $definitionSchema->getClassPath(), + $definitionSchema->getClassName(), + ]))); + } + } + + return $definition->resolveReference( + $propertyName, + $path, + $required, + $propertySchema->getJson()['_dependencies'] ?? null, + ); + } + } catch (Exception $exception) { + throw new SchemaException( + "Unresolved Reference $reference in file {$propertySchema->getFile()}", + 0, + $exception, + ); + } + + throw new SchemaException("Unresolved Reference $reference in file {$propertySchema->getFile()}"); + } + + /** + * Resolve a $ref on a base-level schema: set up definitions, delegate to processReference, + * then copy the referenced schema's properties to the parent schema. + * + * @throws SchemaException + */ + private function processBaseReference( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + bool $required, + ): PropertyInterface { + $schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema); + + $property = $this->processReference($schemaProcessor, $schema, $propertyName, $propertySchema, $required); + + if (!$property->getNestedSchema()) { + throw new SchemaException( + sprintf( + 'A referenced schema on base level must provide an object definition for property %s in file %s', + $propertyName, + $propertySchema->getFile(), + ) + ); } - return $this->processorFactory - ->getProcessor( - $json['type'] ?? 'any', - $propertyMetaDataCollection, + 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(), ) - ->process($propertyName, $propertySchema); + ); + } + + 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 @@ -getCompositionProcessor(), AllOfProcessor::class, true)) { + if (is_a($validator->getCompositionProcessor(), AllOfValidatorFactory::class, true)) { return array_values(array_unique(array_merge(...$requiredPerBranch))); } diff --git a/src/SchemaProcessor/SchemaProcessor.php b/src/SchemaProcessor/SchemaProcessor.php index 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/Utils/TypeConverter.php b/src/Utils/TypeConverter.php index 1dd4dfbd..c09c77b8 100644 --- a/src/Utils/TypeConverter.php +++ b/src/Utils/TypeConverter.php @@ -15,4 +15,13 @@ public static function gettypeToInternal(string $type): string 'NULL' => 'null', ][$type] ?? $type; } + + public static function jsonSchemaToPhp(string $type): string + { + return [ + 'integer' => 'int', + 'number' => 'float', + 'boolean' => 'bool', + ][$type] ?? $type; + } } diff --git a/tests/Basic/FilterTest.php b/tests/Basic/FilterTest.php index 3e5fd5b6..682d4b4b 100644 --- a/tests/Basic/FilterTest.php +++ b/tests/Basic/FilterTest.php @@ -386,9 +386,7 @@ protected function getCustomTransformingFilter( string $token = 'customTransformingFilter', array $acceptedTypes = ['string'], ): TransformingFilterInterface { - return new class ($customSerializer, $customFilter, $token, $acceptedTypes) - extends TrimFilter - implements TransformingFilterInterface + return new class ($customSerializer, $customFilter, $token, $acceptedTypes) extends TrimFilter implements TransformingFilterInterface { public function __construct( private readonly array $customSerializer, @@ -484,8 +482,7 @@ public function testFilterExceptionsAreCaught(): void $this->expectExceptionMessage(<<generateClassFromFile( 'TransformingFilter.json', @@ -799,7 +796,7 @@ public function testFilterWhichAppliesToMultiTypePropertyPartiallyThrowsAnExcept { $this->expectException(SchemaException::class); $this->expectExceptionMessage( - 'Filter trim is not compatible with property type null for property filteredProperty', + 'Filter trim is not compatible with property type string|null for property filteredProperty', ); $this->generateClassFromFile( diff --git a/tests/Draft/DraftTest.php b/tests/Draft/DraftTest.php new file mode 100644 index 00000000..7cde1e94 --- /dev/null +++ b/tests/Draft/DraftTest.php @@ -0,0 +1,88 @@ +expectException(SchemaException::class); + + (new Draft_07())->getDefinition()->build()->getCoveredTypes('nonexistent'); + } + + // --- AutoDetectionDraft --- + + public function testAutoDetectionReturnsDraft07ForDraft07SchemaKeyword(): void + { + $jsonSchema = new JsonSchema('test.json', [ + '$schema' => 'http://json-schema.org/draft-07/schema#', + ]); + + $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + } + + public function testAutoDetectionFallsBackToDraft07WhenSchemaKeywordAbsent(): void + { + $jsonSchema = new JsonSchema('test.json', ['type' => 'object']); + + $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + } + + public function testAutoDetectionFallsBackToDraft07ForUnrecognisedSchemaKeyword(): void + { + $jsonSchema = new JsonSchema('test.json', ['$schema' => 'https://example.com/custom-schema']); + + $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + } + + // --- GeneratorConfiguration --- + + public function testGeneratorConfigurationDefaultDraftIsAutoDetection(): void + { + $this->assertInstanceOf(AutoDetectionDraft::class, (new GeneratorConfiguration())->getDraft()); + } + + public function testGeneratorConfigurationAcceptsDraftInterface(): void + { + $draft = new Draft_07(); + $config = (new GeneratorConfiguration())->setDraft($draft); + + $this->assertSame($draft, $config->getDraft()); + } + + public function testGeneratorConfigurationAcceptsDraftFactoryInterface(): void + { + $factory = new AutoDetectionDraft(); + $config = (new GeneratorConfiguration())->setDraft($factory); + + $this->assertSame($factory, $config->getDraft()); + } + + public function testGeneratorConfigurationSetDraftReturnsSelf(): void + { + $config = new GeneratorConfiguration(); + + $this->assertSame($config, $config->setDraft(new Draft_07())); + } + + public function testGeneratorConfigurationSetDraftFactoryReturnsSelf(): void + { + $config = new GeneratorConfiguration(); + + $this->assertSame($config, $config->setDraft(new AutoDetectionDraft())); + } +} diff --git a/tests/Draft/Modifier/DefaultValueModifierTest.php b/tests/Draft/Modifier/DefaultValueModifierTest.php new file mode 100644 index 00000000..1c5ee7db --- /dev/null +++ b/tests/Draft/Modifier/DefaultValueModifierTest.php @@ -0,0 +1,121 @@ +schemaProcessor = new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + new GeneratorConfiguration(), + new RenderQueue(), + ); + + $this->schema = new Schema('', '', '', new JsonSchema('', [])); + } + + private function modifier(): DefaultValueModifier + { + return new DefaultValueModifier(); + } + + public function testSetsDefaultValueWhenPresent(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'string', 'default' => 'hello']); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame("'hello'", $property->getDefaultValue()); + } + + public function testDoesNothingWhenNoDefaultPresent(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'string']); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertNull($property->getDefaultValue()); + } + + public function testThrowsForInvalidDefaultType(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessage('Invalid type for default value of property name'); + + $jsonSchema = new JsonSchema('test.json', ['type' => 'string', 'default' => 42]); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + } + + public function testCoercesIntegerDefaultToFloatForNumberType(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'number', 'default' => 5]); + $property = new Property('price', new PropertyType('float'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame('5.0', $property->getDefaultValue()); + } + + public function testThrowsForNonNumericDefaultOnNumberType(): void + { + $this->expectException(SchemaException::class); + + $jsonSchema = new JsonSchema('test.json', ['type' => 'number', 'default' => 'not-a-number']); + $property = new Property('price', new PropertyType('float'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + } + + public function testIntegerDefaultValue(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'integer', 'default' => 42]); + $property = new Property('count', new PropertyType('integer'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame('42', $property->getDefaultValue()); + } + + public function testBooleanDefaultValue(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'boolean', 'default' => true]); + $property = new Property('flag', new PropertyType('boolean'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame('true', $property->getDefaultValue()); + } + + public function testSetsDefaultValueWhenNoTypeInSchema(): void + { + $jsonSchema = new JsonSchema('', ['default' => 'anything']); + $property = new Property('val', new PropertyType('string'), $jsonSchema); + + $this->modifier()->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $this->assertSame("'anything'", $property->getDefaultValue()); + } +} diff --git a/tests/Draft/Modifier/TypeCheckModifierTest.php b/tests/Draft/Modifier/TypeCheckModifierTest.php new file mode 100644 index 00000000..f7d941b2 --- /dev/null +++ b/tests/Draft/Modifier/TypeCheckModifierTest.php @@ -0,0 +1,172 @@ +schemaProcessor = new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + new GeneratorConfiguration(), + new RenderQueue(), + ); + + $this->schema = new Schema('', '', '', new JsonSchema('', [])); + } + + public function testAddsTypeCheckValidatorForType(): void + { + $property = new Property('name', new PropertyType('string'), new JsonSchema('', ['type' => 'string'])); + $property->setRequired(true); + + $modifier = new TypeCheckModifier('string'); + $modifier->modify($this->schemaProcessor, $this->schema, $property, new JsonSchema('', ['type' => 'string'])); + + $typeCheckValidators = array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + ); + + $this->assertCount(1, $typeCheckValidators); + $this->assertContains('string', reset($typeCheckValidators)->getValidator()->getTypes()); + } + + public function testDoesNotAddDuplicateTypeCheckValidator(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'string']); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + $property->setRequired(true); + + // Add a TypeCheckValidator manually first + $property->addValidator(new TypeCheckValidator('string', $property, false), 2); + + $modifier = new TypeCheckModifier('string'); + $modifier->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $typeCheckValidators = array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + ); + + $this->assertCount(1, $typeCheckValidators); + } + + public function testDoesNotSkipWhenExistingValidatorIsForDifferentType(): void + { + $jsonSchema = new JsonSchema('', ['type' => 'integer']); + $property = new Property('name', new PropertyType('int'), $jsonSchema); + $property->setRequired(true); + + // Add a TypeCheckValidator for a different type + $property->addValidator(new TypeCheckValidator('string', $property, false), 2); + + $modifier = new TypeCheckModifier('int'); + $modifier->modify($this->schemaProcessor, $this->schema, $property, $jsonSchema); + + $typeCheckValidators = array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + ); + + $this->assertCount(2, $typeCheckValidators); + } + + // --- Type auto-registration --- + + #[DataProvider('typeCheckAutoRegistrationProvider')] + public function testTypeAutoRegistersTypeCheckModifier(string $jsonType, string $phpType): void + { + $type = new Type($jsonType); + + $modifiers = $type->getModifiers(); + $this->assertCount(1, $modifiers); + $this->assertInstanceOf(TypeCheckModifier::class, $modifiers[0]); + + // Verify the modifier adds a validator for the correct PHP type + $property = new Property('x', new PropertyType($phpType), new JsonSchema('', ['type' => $jsonType])); + $property->setRequired(true); + $schema = new Schema('', '', '', new JsonSchema('', [])); + $schemaProcessor = new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + new GeneratorConfiguration(), + new RenderQueue(), + ); + + $modifiers[0]->modify($schemaProcessor, $schema, $property, new JsonSchema('', ['type' => $jsonType])); + + $typeCheckValidators = array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + ); + $this->assertCount(1, $typeCheckValidators); + $this->assertContains($phpType, reset($typeCheckValidators)->getValidator()->getTypes()); + } + + public static function typeCheckAutoRegistrationProvider(): array + { + return [ + 'array' => ['array', 'array'], + 'string' => ['string', 'string'], + 'integer' => ['integer', 'int'], + 'number' => ['number', 'float'], + 'boolean' => ['boolean', 'bool'], + 'null' => ['null', 'null'], + ]; + } + + public function testTypeWithTypeCheckFalseRegistersNoModifiers(): void + { + $type = new Type('object', false); + $this->assertEmpty($type->getModifiers()); + } + + public function testAllowsImplicitNullForOptionalProperty(): void + { + $config = (new GeneratorConfiguration())->setImplicitNull(true); + $schemaProcessor = new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + $config, + new RenderQueue(), + ); + + $jsonSchema = new JsonSchema('', ['type' => 'string']); + $property = new Property('name', new PropertyType('string'), $jsonSchema); + $property->setRequired(false); + + $modifier = new TypeCheckModifier('string'); + $modifier->modify($schemaProcessor, $this->schema, $property, $jsonSchema); + + $validators = array_values(array_filter( + $property->getValidators(), + static fn($v) => $v->getValidator() instanceof TypeCheckInterface, + )); + + $this->assertCount(1, $validators); + // The check should allow $value === null for an optional property with implicit null enabled + $this->assertStringContainsString('null', $validators[0]->getValidator()->getCheck()); + } +} diff --git a/tests/Objects/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, <<expectException(SchemaException::class); 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('', [])), - ); - } -}