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('', [])),
- );
- }
-}