diff --git a/config/dto.schema.json b/config/dto.schema.json index 04f2771..ba96e42 100644 --- a/config/dto.schema.json +++ b/config/dto.schema.json @@ -37,6 +37,10 @@ "type": "boolean", "description": "Whether the DTO is immutable (no setters)" }, + "readonlyProperties": { + "type": "boolean", + "description": "Whether to use public readonly properties (implies immutable)" + }, "traits": { "oneOf": [ { @@ -105,6 +109,32 @@ }, "defaultValue": { "description": "Default value for the field" + }, + "minLength": { + "type": "integer", + "minimum": 0, + "description": "Minimum string length" + }, + "maxLength": { + "type": "integer", + "minimum": 0, + "description": "Maximum string length" + }, + "min": { + "type": "number", + "description": "Minimum numeric value" + }, + "max": { + "type": "number", + "description": "Maximum numeric value" + }, + "pattern": { + "type": "string", + "description": "Regex pattern for string validation" + }, + "lazy": { + "type": "boolean", + "description": "Whether to defer hydration until first access" } }, "required": ["type"], diff --git a/config/dto.xsd b/config/dto.xsd index 21f310f..f9ff014 100644 --- a/config/dto.xsd +++ b/config/dto.xsd @@ -26,6 +26,12 @@ + + + + + + @@ -34,6 +40,7 @@ + diff --git a/docs/ConfigBuilder.md b/docs/ConfigBuilder.md index 51a5185..b4cdb4f 100644 --- a/docs/ConfigBuilder.md +++ b/docs/ConfigBuilder.md @@ -447,3 +447,68 @@ Simple fields without modifiers are optimized to just the type string: ```php Field::string('name') // Produces: 'string' (not ['type' => 'string']) ``` + +## Validation Rules + +Fields support built-in validation rules that are checked when the DTO is constructed: + +```php +Dto::create('User')->fields( + Field::string('name')->required()->minLength(2)->maxLength(100), + Field::string('email')->pattern('/^[^@]+@[^@]+\.[^@]+$/'), + Field::int('age')->min(0)->max(150), + Field::float('score')->min(0.0)->max(100.0), +) +``` + +### Available Validation Methods + +| Method | Applies To | Description | +|--------|-----------|-------------| +| `minLength(int)` | string | Minimum string length (via `mb_strlen`) | +| `maxLength(int)` | string | Maximum string length (via `mb_strlen`) | +| `min(int\|float)` | int, float | Minimum numeric value | +| `max(int\|float)` | int, float | Maximum numeric value | +| `pattern(string)` | string | Regex pattern (must match via `preg_match`) | + +Null fields skip validation — rules are only checked when a value is present. + +On failure, an `InvalidArgumentException` is thrown with a descriptive message. + +## Lazy Properties + +DTO and collection fields can be marked as lazy, deferring hydration until first access: + +```php +Dto::create('Order')->fields( + Field::int('id')->required(), + Field::dto('customer', 'Customer')->asLazy(), + Field::collection('items', 'OrderItem')->singular('item')->asLazy(), +) +``` + +Lazy fields store raw array data during construction. When the getter is called for the first time, +the raw data is hydrated into the DTO/collection. If `toArray()` is called before the getter, +the raw data is returned directly — avoiding unnecessary object creation. + +This is useful for large nested structures where not all fields are always accessed. + +## Readonly Properties + +DTOs can use PHP's `readonly` modifier for true immutability at the language level: + +```php +Dto::create('Config')->readonlyProperties()->fields( + Field::string('host')->required(), + Field::int('port')->default(8080), +) +``` + +This generates `public readonly` properties instead of `protected` ones, providing: + +- Direct public property access (`$dto->host` instead of `$dto->getHost()`) +- Compile-time immutability enforcement (assignment after construction throws `\Error`) +- Getters are still generated for consistency + +Note: `readonlyProperties()` implies `immutable` — the DTO will extend `AbstractImmutableDto` +and use `with*()` methods (which reconstruct from array) instead of setters. diff --git a/docs/Validation.md b/docs/Validation.md index ad1e54c..34a742f 100644 --- a/docs/Validation.md +++ b/docs/Validation.md @@ -4,7 +4,9 @@ This document explains what validation php-collective/dto provides and how to in ## Built-in Validation -The library provides **required field validation** only. When a field is marked as `required`, the DTO will throw an exception if that field is missing or null during instantiation. +### Required Fields + +When a field is marked as `required`, the DTO will throw an exception if that field is missing or null during instantiation. ```xml @@ -15,25 +17,66 @@ The library provides **required field validation** only. When a field is marked ``` ```php -// This throws RuntimeException: Required field 'email' is missing +// This throws InvalidArgumentException: Required fields missing: email $user = new UserDto(['id' => 1]); // This works - nickname is optional $user = new UserDto(['id' => 1, 'email' => 'test@example.com']); ``` -## What's NOT Included +### Validation Rules + +Fields support built-in validation rules for common constraints: + +```php +use PhpCollective\Dto\Config\Dto; +use PhpCollective\Dto\Config\Field; +use PhpCollective\Dto\Config\Schema; + +return Schema::create() + ->dto(Dto::create('User')->fields( + Field::string('name')->required()->minLength(2)->maxLength(100), + Field::string('email')->required()->pattern('/^[^@]+@[^@]+\.[^@]+$/'), + Field::int('age')->min(0)->max(150), + Field::float('score')->min(0.0)->max(100.0), + )) + ->toArray(); +``` + +Or in XML: + +```xml + + + + + + +``` + +#### Available Rules -The library intentionally does **not** include: +| Rule | Applies To | Description | +|------|-----------|-------------| +| `minLength` | string | Minimum string length (via `mb_strlen`) | +| `maxLength` | string | Maximum string length (via `mb_strlen`) | +| `min` | int, float | Minimum numeric value (inclusive) | +| `max` | int, float | Maximum numeric value (inclusive) | +| `pattern` | string | Regex pattern that must match (via `preg_match`) | -- Min/max length validation -- Numeric range validation (min, max) -- Pattern/regex validation -- Email/URL format validation -- Custom validation rules -- Validation error messages +#### Behavior -**Why?** The library focuses on **data structure and transfer**, not business logic validation. This keeps the generated code lean and allows you to choose your preferred validation approach. +- Null fields skip validation — rules are only checked when a value is present +- Required check runs before validation rules +- On failure, an `InvalidArgumentException` is thrown with a descriptive message: + +```php +// InvalidArgumentException: Validation failed: name must be at least 2 characters +$user = new UserDto(['name' => 'A', 'email' => 'a@b.com']); + +// InvalidArgumentException: Validation failed: email must match pattern /^[^@]+@[^@]+\.[^@]+$/ +$user = new UserDto(['name' => 'Test', 'email' => 'invalid']); +``` ## Integrating with Validation Libraries @@ -185,11 +228,11 @@ This is PHP's native type system at work, not library validation. |---------|:------------------:|:------------------:| | Required fields | ✅ | ✅ | | Type checking | ✅ (PHP native) | ✅ | -| Min/max length | ❌ | ✅ | -| Numeric ranges | ❌ | ✅ | -| Regex patterns | ❌ | ✅ | -| Email/URL format | ❌ | ✅ | +| Min/max length | ✅ | ✅ | +| Numeric ranges | ✅ | ✅ | +| Regex patterns | ✅ | ✅ | +| Email/URL format | ✅ (via pattern) | ✅ | | Custom rules | ❌ | ✅ | | Error messages | Basic | Rich | -**Recommendation:** Use php-collective/dto for structure and a validation library for business rules. This separation of concerns keeps each tool focused on what it does best. +**Recommendation:** Use the built-in validation rules for simple structural constraints. For complex business logic validation (conditional rules, cross-field dependencies, custom messages), use a dedicated validation library alongside your DTOs. diff --git a/src/Config/DtoBuilder.php b/src/Config/DtoBuilder.php index 7a720a6..2a9d89d 100644 --- a/src/Config/DtoBuilder.php +++ b/src/Config/DtoBuilder.php @@ -19,6 +19,8 @@ class DtoBuilder protected bool $immutable = false; + protected bool $readonlyProperties = false; + protected ?string $extends = null; protected ?string $deprecated = null; @@ -97,6 +99,21 @@ public function asImmutable(): static return $this; } + /** + * Generate readonly properties for this DTO. + * + * When enabled, properties are declared with the `readonly` keyword + * and `with*()` methods use Reflection to set values on cloned instances. + * Implies immutable. + */ + public function readonlyProperties(): static + { + $this->readonlyProperties = true; + $this->immutable = true; + + return $this; + } + /** * Mark this DTO as deprecated. */ @@ -140,6 +157,10 @@ public function toArray(): array $config['immutable'] = true; } + if ($this->readonlyProperties) { + $config['readonlyProperties'] = true; + } + if ($this->extends !== null) { $config['extends'] = $this->extends; } diff --git a/src/Config/FieldBuilder.php b/src/Config/FieldBuilder.php index 4219a68..1d15b8b 100644 --- a/src/Config/FieldBuilder.php +++ b/src/Config/FieldBuilder.php @@ -51,6 +51,18 @@ class FieldBuilder protected ?string $transformTo = null; + protected ?int $minLength = null; + + protected ?int $maxLength = null; + + protected int|float|null $min = null; + + protected int|float|null $max = null; + + protected ?string $pattern = null; + + protected bool $lazy = false; + public function __construct(string $name, string $type) { $this->name = $name; @@ -323,6 +335,68 @@ public function getName(): string return $this->name; } + /** + * Set minimum string length validation. + */ + public function minLength(int $length): static + { + $this->minLength = $length; + + return $this; + } + + /** + * Set maximum string length validation. + */ + public function maxLength(int $length): static + { + $this->maxLength = $length; + + return $this; + } + + /** + * Set minimum numeric value validation. + */ + public function min(int|float $value): static + { + $this->min = $value; + + return $this; + } + + /** + * Set maximum numeric value validation. + */ + public function max(int|float $value): static + { + $this->max = $value; + + return $this; + } + + /** + * Set regex pattern validation. + * + * @example Field::string('email')->pattern('/^[^@]+@[^@]+$/') + */ + public function pattern(string $regex): static + { + $this->pattern = $regex; + + return $this; + } + + /** + * Mark field as lazy-loaded. Nested DTO/collection fields will be hydrated on first access. + */ + public function asLazy(): static + { + $this->lazy = true; + + return $this; + } + /** * Build the field configuration array. * @@ -343,7 +417,13 @@ public function toArray(): array|string $this->mapFrom === null && $this->mapTo === null && $this->transformFrom === null && - $this->transformTo === null + $this->transformTo === null && + $this->minLength === null && + $this->maxLength === null && + $this->min === null && + $this->max === null && + $this->pattern === null && + !$this->lazy ) { return $this->type; } @@ -406,6 +486,30 @@ public function toArray(): array|string $config['transformTo'] = $this->transformTo; } + if ($this->minLength !== null) { + $config['minLength'] = $this->minLength; + } + + if ($this->maxLength !== null) { + $config['maxLength'] = $this->maxLength; + } + + if ($this->min !== null) { + $config['min'] = $this->min; + } + + if ($this->max !== null) { + $config['max'] = $this->max; + } + + if ($this->pattern !== null) { + $config['pattern'] = $this->pattern; + } + + if ($this->lazy) { + $config['lazy'] = true; + } + return $config; } } diff --git a/src/Dto/Dto.php b/src/Dto/Dto.php index 6f20149..43d3b4e 100644 --- a/src/Dto/Dto.php +++ b/src/Dto/Dto.php @@ -230,6 +230,13 @@ public static function create(?array $data = null, bool $ignoreMissing = false, */ protected array $_touchedFields = []; + /** + * Holds raw data for lazy-loaded fields. Hydrated on first access. + * + * @var array + */ + protected array $_lazyData = []; + /** * @param array|null $data * @param bool $ignoreMissing @@ -1038,6 +1045,31 @@ protected function validate(): void if ($errors) { throw new InvalidArgumentException('Required fields missing: ' . implode(', ', $errors)); } + + $validationErrors = []; + foreach ($this->_metadata as $name => $field) { + if ($this->$name === null) { + continue; + } + if (!empty($field['minLength']) && is_string($this->$name) && mb_strlen($this->$name) < $field['minLength']) { + $validationErrors[] = $name . ' must be at least ' . $field['minLength'] . ' characters'; + } + if (!empty($field['maxLength']) && is_string($this->$name) && mb_strlen($this->$name) > $field['maxLength']) { + $validationErrors[] = $name . ' must be at most ' . $field['maxLength'] . ' characters'; + } + if (isset($field['min']) && is_numeric($this->$name) && $this->$name < $field['min']) { + $validationErrors[] = $name . ' must be at least ' . $field['min']; + } + if (isset($field['max']) && is_numeric($this->$name) && $this->$name > $field['max']) { + $validationErrors[] = $name . ' must be at most ' . $field['max']; + } + if (!empty($field['pattern']) && is_string($this->$name) && !preg_match($field['pattern'], $this->$name)) { + $validationErrors[] = $name . ' must match pattern ' . $field['pattern']; + } + } + if ($validationErrors) { + throw new InvalidArgumentException('Validation failed: ' . implode(', ', $validationErrors)); + } } /** diff --git a/src/Engine/XmlEngine.php b/src/Engine/XmlEngine.php index a8516b1..d0f3ccd 100644 --- a/src/Engine/XmlEngine.php +++ b/src/Engine/XmlEngine.php @@ -107,7 +107,7 @@ public function parse(string $content): array */ protected function castBoolValue(string|float|int|bool $value, ?string $key = null): string|float|int|bool { - if ($key && !in_array($key, ['required', 'immutable', 'collection', 'associative'], true)) { + if ($key && !in_array($key, ['required', 'immutable', 'readonlyProperties', 'collection', 'associative', 'lazy'], true)) { return $value; } @@ -130,6 +130,14 @@ protected function castBoolValue(string|float|int|bool $value, ?string $key = nu */ protected function castDefaultValue($value, string $key, array $fieldDefinition) { + if (in_array($key, ['minLength', 'maxLength'], true)) { + return (int)$value; + } + + if (in_array($key, ['min', 'max'], true)) { + return str_contains((string)$value, '.') ? (float)$value : (int)$value; + } + if (!in_array($key, ['defaultValue'], true) || empty($fieldDefinition['@type'])) { return $value; } diff --git a/src/Generator/Builder.php b/src/Generator/Builder.php index d6de1ef..89814a9 100644 --- a/src/Generator/Builder.php +++ b/src/Generator/Builder.php @@ -87,6 +87,12 @@ class Builder 'mapTo', 'transformFrom', 'transformTo', + 'minLength', + 'maxLength', + 'min', + 'max', + 'pattern', + 'lazy', ]; /** @@ -204,6 +210,7 @@ protected function createDtos(array $config, string $namespace): array $dto += [ 'immutable' => $this->config['immutable'], + 'readonlyProperties' => false, 'namespace' => $namespace . '\Dto', 'className' => $name . $this->getConfigOrFail('suffix'), 'extends' => '\\PhpCollective\\Dto\\Dto\\AbstractDto', @@ -212,6 +219,10 @@ protected function createDtos(array $config, string $namespace): array $dto['traits'] = $this->normalizeTraits($dto['traits']); + if (!empty($dto['readonlyProperties'])) { + $dto['immutable'] = true; + } + if (!empty($dto['immutable']) && $dto['extends'] === '\\PhpCollective\\Dto\\Dto\\AbstractDto') { $dto['extends'] = '\\PhpCollective\\Dto\\Dto\\AbstractImmutableDto'; } diff --git a/src/Generator/FieldCompletor.php b/src/Generator/FieldCompletor.php index 867e57f..75e854a 100644 --- a/src/Generator/FieldCompletor.php +++ b/src/Generator/FieldCompletor.php @@ -140,6 +140,12 @@ protected function addFieldDefaults(array $fields): array 'mapTo' => null, 'transformFrom' => null, 'transformTo' => null, + 'minLength' => null, + 'maxLength' => null, + 'min' => null, + 'max' => null, + 'pattern' => null, + 'lazy' => false, ]; if ($data['required']) { diff --git a/templates/dto.twig b/templates/dto.twig index dc9fe47..829a374 100644 --- a/templates/dto.twig +++ b/templates/dto.twig @@ -42,10 +42,10 @@ class {{ className }} extends {% if extends starts with '\\' %}{{ extends[1:] | {% endif %} {% include 'element/constants.twig' with {'fields': fields} only %} -{% include 'element/properties.twig' with {'fields': fields} only -%} +{% include 'element/properties.twig' with {'fields': fields, 'readonlyProperties': readonlyProperties|default(false)} only -%} {% include 'element/metadata.twig' with {'metaData': metaData} only %} {% include 'element/map.twig' with {'fields': fields} only %} -{% include 'element/optimizations.twig' with {'fields': fields, 'immutable': immutable} only %} -{% include 'element/methods.twig' with {'fields': fields, 'immutable': immutable, 'scalarAndReturnTypes': scalarAndReturnTypes} only -%} +{% include 'element/optimizations.twig' with {'fields': fields, 'immutable': immutable, 'readonlyProperties': readonlyProperties|default(false)} only %} +{% include 'element/methods.twig' with {'fields': fields, 'immutable': immutable, 'readonlyProperties': readonlyProperties|default(false), 'scalarAndReturnTypes': scalarAndReturnTypes} only -%} {% include 'element/array_shape.twig' with {'arrayShape': arrayShape} only %} } diff --git a/templates/element/method_get.twig b/templates/element/method_get.twig index 9968213..65446a2 100644 --- a/templates/element/method_get.twig +++ b/templates/element/method_get.twig @@ -9,6 +9,40 @@ public function get{{ name|stripLeadingUnderscore|camelize }}(){% if returnTypeHint %}: {{ nullableReturnTypeHint ?? returnTypeHint }}{% endif %} { +{% if lazy|default(false) %} + if (isset($this->_lazyData['{{ name }}'])) { + $raw = $this->_lazyData['{{ name }}']; + unset($this->_lazyData['{{ name }}']); +{% if dto %} + if (is_array($raw)) { + $this->{{ name }} = new {{ typeHint }}($raw); + } else { + $this->{{ name }} = $raw; + } +{% elseif collection and collectionType != 'array' and singularClass is defined and singularClass %} +{% set adapter = getCollectionAdapter(collectionType) %} + $collection = {{ adapter.getCreateEmptyCode(collectionType) }}; + foreach ($raw as $key => $item) { + if (is_array($item)) { + $item = new {{ singularClass }}($item); + } + {{ adapter.getAppendCode('$collection', '$item') }} + } + $this->{{ name }} = $collection; +{% elseif collection and collectionType == 'array' and singularClass is defined and singularClass %} + $collection = []; + foreach ($raw as $key => $item) { + if (is_array($item)) { + $item = new {{ singularClass }}($item); + } + $collection[$key] = $item; + } + $this->{{ name }} = $collection; +{% else %} + $this->{{ name }} = $raw; +{% endif %} + } +{% endif %} {% if collection %} if ($this->{{ name }} === null) { return {% if collectionType == 'array' %}[]{% else %}new {{ typeHint }}([]){% endif %}; diff --git a/templates/element/method_with.twig b/templates/element/method_with.twig index e7d0356..1a852a8 100644 --- a/templates/element/method_with.twig +++ b/templates/element/method_with.twig @@ -9,6 +9,18 @@ */ public function with{{ name|stripLeadingUnderscore|camelize }}({% if typeHint %}{{ nullableTypeHint ?? typeHint }} {% endif %}${{ name }}{% if typeHint and nullable %} = null{% endif %}) { +{% if readonlyProperties|default(false) %} + $data = $this->toArray(); +{% if isArray or typeHint == 'array' %} + $data['{{ name }}'] = ${{ name }} !== null ? $this->cloneArray(${{ name }}) : null; +{% elseif isDto %} + $data['{{ name }}'] = ${{ name }} !== null ? ${{ name }}->toArray() : null; +{% else %} + $data['{{ name }}'] = ${{ name }}; +{% endif %} + + return new static($data); +{% else %} $new = clone $this; {% if isArray or typeHint == 'array' %} $new->{{ name }} = ${{ name }} !== null ? $new->cloneArray(${{ name }}) : null; @@ -18,4 +30,5 @@ $new->_touchedFields[static::FIELD_{{ name | stripLeadingUnderscore | camelize | underscore | upper }}] = true; return $new; +{% endif %} } diff --git a/templates/element/method_with_added.twig b/templates/element/method_with_added.twig index 44ada01..a3171b8 100644 --- a/templates/element/method_with_added.twig +++ b/templates/element/method_with_added.twig @@ -13,6 +13,19 @@ */ public function withAdded{{ singular|stripLeadingUnderscore|camelize }}({% if associative %}$key, {% endif %}{% if singularTypeHint %}{% if singularNullable %}?{% endif %}{{ singularTypeHint }} {% endif %}${{ singular }}) { +{% if readonlyProperties|default(false) %} + $data = $this->toArray(); + if (!isset($data['{{ name }}'])) { + $data['{{ name }}'] = []; + } +{% if associative %} + $data['{{ name }}'][$key] = ${{ singular }}; +{% else %} + $data['{{ name }}'][] = ${{ singular }}; +{% endif %} + + return new static($data); +{% else %} $new = clone $this; if ($new->{{ name }} === null) { @@ -29,5 +42,6 @@ $new->_touchedFields[static::FIELD_{{ name | stripLeadingUnderscore | camelize | underscore | upper }}] = true; return $new; +{% endif %} } {% endif -%} diff --git a/templates/element/method_with_removed.twig b/templates/element/method_with_removed.twig index a3bcf15..7241476 100644 --- a/templates/element/method_with_removed.twig +++ b/templates/element/method_with_removed.twig @@ -10,6 +10,14 @@ */ public function withRemoved{{ singular|stripLeadingUnderscore|camelize }}($key) { +{% if readonlyProperties|default(false) %} + $data = $this->toArray(); + if (isset($data['{{ name }}'])) { + unset($data['{{ name }}'][$key]); + } + + return new static($data); +{% else %} $new = clone $this; if ($new->{{ name }} === null) { @@ -27,5 +35,6 @@ $new->_touchedFields[static::FIELD_{{ name | stripLeadingUnderscore | camelize | underscore | upper }}] = true; return $new; +{% endif %} } {% endif -%} diff --git a/templates/element/methods.twig b/templates/element/methods.twig index ebdf76e..4501e22 100644 --- a/templates/element/methods.twig +++ b/templates/element/methods.twig @@ -1,6 +1,6 @@ {% set blank = "\n" -%} {% for field in fields -%} -{% set fieldVars = field|merge({'scalarAndReturnTypes': scalarAndReturnTypes}) -%} +{% set fieldVars = field|merge({'scalarAndReturnTypes': scalarAndReturnTypes, 'readonlyProperties': readonlyProperties|default(false)}) -%} {% if immutable %}{% include 'element/method_with.twig' with fieldVars only %}{% else %}{% include 'element/method_set.twig' with fieldVars only %}{% endif -%} {% if field.nullable and immutable %}{{ blank }}{% include 'element/method_with_or_fail.twig' with fieldVars only %}{% elseif field.nullable %}{{ blank }}{% include 'element/method_set_or_fail.twig' with fieldVars only %}{% endif -%} {{ blank }}{% include 'element/method_get.twig' with fieldVars only -%} diff --git a/templates/element/optimizations.twig b/templates/element/optimizations.twig index b892e01..c56a6ab 100644 --- a/templates/element/optimizations.twig +++ b/templates/element/optimizations.twig @@ -22,7 +22,16 @@ protected function setFromArrayFast(array $data): void { {% for field in fields %} -{% if field.dto %} +{% if field.lazy|default(false) %} + if (isset($data['{{ field.name }}'])) { +{% if field.transformFrom %} + $this->_lazyData['{{ field.name }}'] = $this->transformValue('{{ field.transformFrom }}', $data['{{ field.name }}']); +{% else %} + $this->_lazyData['{{ field.name }}'] = $data['{{ field.name }}']; +{% endif %} + $this->_touchedFields['{{ field.name }}'] = true; + } +{% elseif field.dto %} if (isset($data['{{ field.name }}'])) { $value = $data['{{ field.name }}']; {% if field.transformFrom %} @@ -150,7 +159,13 @@ return [ {% for field in fields %} {% set key = field.mapTo is not null ? field.mapTo : field.name %} -{% if field.dto %} +{% if field.lazy|default(false) %} +{% if field.transformTo %} + '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->_lazyData['{{ field.name }}'] ?? ($this->{{ field.name }} !== null ? {% if field.dto %}$this->{{ field.name }}->toArray(){% elseif field.collection and field.collectionType != 'array' %}(static function (\Traversable $c): array { $r = []; foreach ($c as $k => $v) { $r[$k] = is_object($v) && $v instanceof \PhpCollective\Dto\Dto\Dto ? $v->toArray() : $v; } return $r; })($this->{{ field.name }}){% else %}$this->{{ field.name }}{% endif %} : null)), +{% else %} + '{{ key }}' => $this->_lazyData['{{ field.name }}'] ?? ($this->{{ field.name }} !== null ? {% if field.dto %}$this->{{ field.name }}->toArray(){% elseif field.collection and field.collectionType != 'array' %}(static function (\Traversable $c): array { $r = []; foreach ($c as $k => $v) { $r[$k] = is_object($v) && $v instanceof \PhpCollective\Dto\Dto\Dto ? $v->toArray() : $v; } return $r; })($this->{{ field.name }}){% else %}$this->{{ field.name }}{% endif %} : null), +{% endif %} +{% elseif field.dto %} {% if field.nullable %} {% if field.transformTo %} '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }} !== null ? $this->{{ field.name }}->toArray() : null), @@ -309,10 +324,14 @@ ]; } {% set fieldsWithDefaults = [] %} +{% set uninitializedNullableFields = [] %} {% for field in fields %} {% if field.defaultValue is not null %} {% set fieldsWithDefaults = fieldsWithDefaults | merge([field]) %} {% endif %} +{% if readonlyProperties|default(false) and field.nullable and field.defaultValue is null %} +{% set uninitializedNullableFields = uninitializedNullableFields | merge([field]) %} +{% endif %} {% endfor %} /** @@ -322,6 +341,13 @@ */ protected function setDefaults() { +{% if readonlyProperties|default(false) and uninitializedNullableFields | length > 0 %} +{% for field in uninitializedNullableFields %} + if (!isset($this->{{ field.name }})) { + $this->{{ field.name }} = null; + } +{% endfor %} +{% endif %} {% if fieldsWithDefaults | length > 0 %} {% for field in fieldsWithDefaults %} {% if field.nullable %} @@ -337,14 +363,18 @@ return $this; } {% set requiredFields = [] %} +{% set validatedFields = [] %} {% for field in fields %} {% if field.required %} {% set requiredFields = requiredFields | merge([field]) %} {% endif %} +{% if field.minLength is not null or field.maxLength is not null or field.min is not null or field.max is not null or field.pattern is not null %} +{% set validatedFields = validatedFields | merge([field]) %} +{% endif %} {% endfor %} /** - * Optimized validate - only checks required fields. + * Optimized validate - checks required fields and validation rules. * * @throws \InvalidArgumentException * @@ -376,5 +406,41 @@ throw new InvalidArgumentException('Required fields missing: ' . implode(', ', $errors)); } } +{% endif %} +{% if validatedFields | length > 0 %} + + $errors = []; +{% for field in validatedFields %} + if ($this->{{ field.name }} !== null) { +{% if field.minLength is not null %} + if (is_string($this->{{ field.name }}) && mb_strlen($this->{{ field.name }}) < {{ field.minLength }}) { + $errors[] = '{{ field.name }} must be at least {{ field.minLength }} characters'; + } +{% endif %} +{% if field.maxLength is not null %} + if (is_string($this->{{ field.name }}) && mb_strlen($this->{{ field.name }}) > {{ field.maxLength }}) { + $errors[] = '{{ field.name }} must be at most {{ field.maxLength }} characters'; + } +{% endif %} +{% if field.min is not null %} + if (is_numeric($this->{{ field.name }}) && $this->{{ field.name }} < {{ field.min }}) { + $errors[] = '{{ field.name }} must be at least {{ field.min }}'; + } +{% endif %} +{% if field.max is not null %} + if (is_numeric($this->{{ field.name }}) && $this->{{ field.name }} > {{ field.max }}) { + $errors[] = '{{ field.name }} must be at most {{ field.max }}'; + } +{% endif %} +{% if field.pattern is not null %} + if (is_string($this->{{ field.name }}) && !preg_match('{{ field.pattern | e('html') }}', $this->{{ field.name }})) { + $errors[] = '{{ field.name }} must match pattern {{ field.pattern | e('html') }}'; + } +{% endif %} + } +{% endfor %} + if ($errors) { + throw new InvalidArgumentException('Validation failed: ' . implode(', ', $errors)); + } {% endif %} } diff --git a/templates/element/properties.twig b/templates/element/properties.twig index f84a34f..6fb37f7 100644 --- a/templates/element/properties.twig +++ b/templates/element/properties.twig @@ -1,4 +1,4 @@ {% for field in fields %} -{% include 'element/property.twig' with field only %} +{% include 'element/property.twig' with field|merge({'readonlyProperties': readonlyProperties|default(false)}) only %} {% endfor -%} diff --git a/templates/element/property.twig b/templates/element/property.twig index db7bde5..a17d14b 100644 --- a/templates/element/property.twig +++ b/templates/element/property.twig @@ -1,4 +1,4 @@ /** * @var {{ docBlockType ?? type }}{% if nullable %}|null{% endif ~%} */ - protected {% if returnTypeHint %}{% if collection and collectionType != 'array' %}?{{ typeHint }}{% else %}{{ nullableTypeHint ?? typeHint }}{% endif %} {% endif %}${{ name }}{% if nullable %} = null{% elseif defaultValue is defined and defaultValue is not null %} = {{ defaultValue | phpExport | raw }}{% elseif collection and collectionType != 'array' %} = null{% endif %}; + {% if readonlyProperties|default(false) %}public readonly{% else %}protected{% endif %} {% if returnTypeHint %}{% if collection and collectionType != 'array' %}?{{ typeHint }}{% else %}{{ nullableTypeHint ?? typeHint }}{% endif %} {% endif %}${{ name }}{% if not readonlyProperties|default(false) %}{% if nullable %} = null{% elseif defaultValue is defined and defaultValue is not null %} = {{ defaultValue | phpExport | raw }}{% elseif collection and collectionType != 'array' %} = null{% endif %}{% endif %}; diff --git a/tests/Config/BuilderTest.php b/tests/Config/BuilderTest.php index 6009ae8..b7e50a6 100644 --- a/tests/Config/BuilderTest.php +++ b/tests/Config/BuilderTest.php @@ -496,4 +496,51 @@ public function testBenchmarkConfigEquivalence(): void $this->assertSame($expected, $schema->toArray()); } + + public function testFieldValidationRules(): void + { + $schema = Schema::create() + ->dto(Dto::create('Validated')->fields( + Field::string('name')->required()->minLength(2)->maxLength(50), + Field::string('email')->pattern('/^[^@]+@[^@]+$/'), + Field::int('age')->min(0)->max(150), + Field::float('score')->min(0.0)->max(100.0), + )); + + $result = $schema->toArray(); + $fields = $result['Validated']['fields']; + + $this->assertSame(2, $fields['name']['minLength']); + $this->assertSame(50, $fields['name']['maxLength']); + $this->assertSame('/^[^@]+@[^@]+$/', $fields['email']['pattern']); + $this->assertSame(0, $fields['age']['min']); + $this->assertSame(150, $fields['age']['max']); + $this->assertSame(0.0, $fields['score']['min']); + $this->assertSame(100.0, $fields['score']['max']); + } + + public function testFieldLazy(): void + { + $schema = Schema::create() + ->dto(Dto::create('WithLazy')->fields( + Field::string('title'), + Field::dto('nested', 'Other')->asLazy(), + )); + + $result = $schema->toArray(); + $this->assertTrue($result['WithLazy']['fields']['nested']['lazy']); + } + + public function testDtoReadonlyProperties(): void + { + $schema = Schema::create() + ->dto(Dto::create('ReadonlyUser')->readonlyProperties()->fields( + Field::string('name')->required(), + Field::int('age'), + )); + + $result = $schema->toArray(); + $this->assertTrue($result['ReadonlyUser']['readonlyProperties']); + $this->assertTrue($result['ReadonlyUser']['immutable']); + } } diff --git a/tests/Dto/LazyTest.php b/tests/Dto/LazyTest.php new file mode 100644 index 0000000..3bcdc8e --- /dev/null +++ b/tests/Dto/LazyTest.php @@ -0,0 +1,130 @@ + 'Test', + 'nested' => ['name' => 'Nested', 'count' => 5], + ]); + + $this->assertSame('Test', $dto->getTitle()); + + $nested = $dto->getNested(); + $this->assertInstanceOf(SimpleDto::class, $nested); + $this->assertSame('Nested', $nested->getName()); + $this->assertSame(5, $nested->getCount()); + } + + public function testLazyCollectionField(): void + { + $dto = new LazyDto([ + 'title' => 'Test', + 'items' => [ + ['name' => 'A', 'count' => 1], + ['name' => 'B', 'count' => 2], + ], + ]); + + $items = $dto->getItems(); + $this->assertCount(2, $items); + $this->assertInstanceOf(SimpleDto::class, $items[0]); + $this->assertSame('A', $items[0]->getName()); + $this->assertSame('B', $items[1]->getName()); + } + + public function testLazyToArrayWithoutHydration(): void + { + $dto = new LazyDto([ + 'title' => 'Test', + 'nested' => ['name' => 'Raw'], + ]); + + $arr = $dto->toArray(); + $this->assertSame('Test', $arr['title']); + $this->assertIsArray($arr['nested']); + $this->assertSame('Raw', $arr['nested']['name']); + } + + public function testLazyToArrayAfterHydration(): void + { + $dto = new LazyDto([ + 'title' => 'Test', + 'nested' => ['name' => 'Hydrated'], + ]); + + $dto->getNested(); + $arr = $dto->toArray(); + $this->assertSame('Hydrated', $arr['nested']['name']); + } + + public function testLazyHasBeforeAndAfterAccess(): void + { + $dto = new LazyDto([ + 'title' => 'Test', + 'nested' => ['name' => 'Sub'], + ]); + + $this->assertTrue($dto->hasNested()); + $dto->getNested(); + $this->assertTrue($dto->hasNested()); + } + + public function testLazyNullField(): void + { + $dto = new LazyDto([ + 'title' => 'Test', + 'nested' => null, + ]); + + $this->assertNull($dto->getNested()); + } + + public function testLazyFieldNotSet(): void + { + $dto = new LazyDto([ + 'title' => 'Test', + ]); + + $this->assertFalse($dto->hasNested()); + $this->assertNull($dto->getNested()); + } + + public function testLazySetterClearsLazyData(): void + { + $dto = new LazyDto([ + 'title' => 'Test', + 'nested' => ['name' => 'Original'], + ]); + + $replacement = new SimpleDto(['name' => 'Replaced']); + $dto->setNested($replacement); + + $this->assertSame('Replaced', $dto->getNested()->getName()); + } + + public function testLazyCollectionToArrayWithoutHydration(): void + { + $rawItems = [ + ['name' => 'X', 'count' => 10], + ['name' => 'Y', 'count' => 20], + ]; + $dto = new LazyDto([ + 'title' => 'Test', + 'items' => $rawItems, + ]); + + $arr = $dto->toArray(); + $this->assertCount(2, $arr['items']); + $this->assertSame('X', $arr['items'][0]['name']); + } +} diff --git a/tests/Dto/ReadonlyTest.php b/tests/Dto/ReadonlyTest.php new file mode 100644 index 0000000..0b69ac5 --- /dev/null +++ b/tests/Dto/ReadonlyTest.php @@ -0,0 +1,123 @@ + 'Alice', 'age' => 30]); + + $this->assertSame('Alice', $dto->getName()); + $this->assertSame(30, $dto->getAge()); + $this->assertNull($dto->getActive()); + } + + public function testPublicReadAccess(): void + { + $dto = new ReadonlyDto(['name' => 'Bob', 'age' => 25, 'active' => true]); + + $this->assertSame('Bob', $dto->name); + $this->assertSame(25, $dto->age); + $this->assertTrue($dto->active); + } + + public function testDirectAssignmentFails(): void + { + $dto = new ReadonlyDto(['name' => 'Charlie']); + + $this->expectException(Error::class); + $dto->name = 'Modified'; + } + + public function testWithMethodReturnsNewInstance(): void + { + $original = new ReadonlyDto(['name' => 'Alice', 'age' => 30]); + $modified = $original->withName('Bob'); + + $this->assertSame('Alice', $original->getName()); + $this->assertSame('Bob', $modified->getName()); + $this->assertSame(30, $modified->getAge()); + $this->assertNotSame($original, $modified); + } + + public function testWithAge(): void + { + $dto = new ReadonlyDto(['name' => 'Test', 'age' => 20]); + $modified = $dto->withAge(25); + + $this->assertSame(20, $dto->getAge()); + $this->assertSame(25, $modified->getAge()); + } + + public function testWithActive(): void + { + $dto = new ReadonlyDto(['name' => 'Test']); + $modified = $dto->withActive(true); + + $this->assertNull($dto->getActive()); + $this->assertTrue($modified->getActive()); + } + + public function testChainedWithMethods(): void + { + $dto = new ReadonlyDto(['name' => 'Start']); + $result = $dto->withName('Changed')->withAge(40)->withActive(false); + + $this->assertSame('Changed', $result->getName()); + $this->assertSame(40, $result->getAge()); + $this->assertFalse($result->getActive()); + } + + public function testToArray(): void + { + $dto = new ReadonlyDto(['name' => 'Test', 'age' => 25, 'active' => true]); + $result = $dto->toArray(); + + $this->assertSame([ + 'name' => 'Test', + 'age' => 25, + 'active' => true, + ], $result); + } + + public function testEmptyConstruction(): void + { + $dto = new ReadonlyDto(); + $this->assertNull($dto->getName()); + $this->assertNull($dto->getAge()); + $this->assertNull($dto->getActive()); + } + + public function testHasMethods(): void + { + $dto = new ReadonlyDto(['name' => 'Test']); + + $this->assertTrue($dto->hasName()); + $this->assertFalse($dto->hasAge()); + $this->assertFalse($dto->hasActive()); + } + + public function testCreateFromArray(): void + { + $dto = ReadonlyDto::createFromArray(['name' => 'Factory', 'age' => 10]); + + $this->assertSame('Factory', $dto->getName()); + $this->assertSame(10, $dto->getAge()); + } + + public function testWithNullValue(): void + { + $dto = new ReadonlyDto(['name' => 'Test', 'age' => 25]); + $modified = $dto->withAge(null); + + $this->assertSame(25, $dto->getAge()); + $this->assertNull($modified->getAge()); + } +} diff --git a/tests/Dto/ValidationTest.php b/tests/Dto/ValidationTest.php new file mode 100644 index 0000000..036f68f --- /dev/null +++ b/tests/Dto/ValidationTest.php @@ -0,0 +1,141 @@ + 'Hello']); + $this->assertSame('Hello', $dto->getName()); + } + + public function testMinLengthFails(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('name must be at least 2 characters'); + + new ValidatedDto(['name' => 'A']); + } + + public function testMaxLengthFails(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('name must be at most 50 characters'); + + new ValidatedDto(['name' => str_repeat('x', 51)]); + } + + public function testMinFails(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('age must be at least 0'); + + new ValidatedDto(['name' => 'Test', 'age' => -1]); + } + + public function testMaxFails(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('age must be at most 150'); + + new ValidatedDto(['name' => 'Test', 'age' => 200]); + } + + public function testMinFloat(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('score must be at least 0'); + + new ValidatedDto(['name' => 'Test', 'score' => -0.1]); + } + + public function testMaxFloat(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('score must be at most 100'); + + new ValidatedDto(['name' => 'Test', 'score' => 100.1]); + } + + public function testPatternFails(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('email must match pattern'); + + new ValidatedDto(['name' => 'Test', 'email' => 'not-an-email']); + } + + public function testPatternPasses(): void + { + $dto = new ValidatedDto(['name' => 'Test', 'email' => 'user@example.com']); + $this->assertSame('user@example.com', $dto->getEmail()); + } + + public function testNullFieldsSkipValidation(): void + { + $dto = new ValidatedDto(['name' => 'Test']); + $this->assertNull($dto->getEmail()); + $this->assertNull($dto->getAge()); + $this->assertNull($dto->getScore()); + } + + public function testAllValidFields(): void + { + $dto = new ValidatedDto([ + 'name' => 'Test', + 'email' => 'a@b.com', + 'age' => 25, + 'score' => 99.5, + ]); + $this->assertSame('Test', $dto->getName()); + $this->assertSame('a@b.com', $dto->getEmail()); + $this->assertSame(25, $dto->getAge()); + $this->assertSame(99.5, $dto->getScore()); + } + + public function testBoundaryValues(): void + { + $dto = new ValidatedDto([ + 'name' => 'AB', + 'age' => 0, + 'score' => 0.0, + ]); + $this->assertSame('AB', $dto->getName()); + $this->assertSame(0, $dto->getAge()); + $this->assertSame(0.0, $dto->getScore()); + + $dto2 = new ValidatedDto([ + 'name' => str_repeat('x', 50), + 'age' => 150, + 'score' => 100.0, + ]); + $this->assertSame(150, $dto2->getAge()); + $this->assertSame(100.0, $dto2->getScore()); + } + + public function testRequiredFieldStillWorks(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Required fields missing: name'); + + new ValidatedDto([]); + } + + public function testToArray(): void + { + $dto = new ValidatedDto(['name' => 'Test', 'age' => 25]); + $result = $dto->toArray(); + + $this->assertSame('Test', $result['name']); + $this->assertSame(25, $result['age']); + $this->assertNull($result['email']); + $this->assertNull($result['score']); + } +} diff --git a/tests/TestDto/LazyDto.php b/tests/TestDto/LazyDto.php new file mode 100644 index 0000000..5cf2ee8 --- /dev/null +++ b/tests/TestDto/LazyDto.php @@ -0,0 +1,315 @@ +|null + */ + protected ?array $items = null; + + /** + * @var array> + */ + protected array $_metadata = [ + 'title' => [ + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => false, + 'collectionType' => null, + 'singularType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'isClass' => false, + 'enum' => null, + 'minLength' => null, + 'maxLength' => null, + 'min' => null, + 'max' => null, + 'pattern' => null, + 'lazy' => false, + ], + 'nested' => [ + 'type' => SimpleDto::class, + 'required' => false, + 'defaultValue' => null, + 'dto' => SimpleDto::class, + 'collectionType' => null, + 'singularType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'isClass' => true, + 'enum' => null, + 'minLength' => null, + 'maxLength' => null, + 'min' => null, + 'max' => null, + 'pattern' => null, + 'lazy' => true, + ], + 'items' => [ + 'type' => SimpleDto::class . '[]', + 'required' => false, + 'defaultValue' => null, + 'dto' => SimpleDto::class, + 'collectionType' => 'array', + 'singularType' => SimpleDto::class, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'isClass' => true, + 'enum' => null, + 'minLength' => null, + 'maxLength' => null, + 'min' => null, + 'max' => null, + 'pattern' => null, + 'lazy' => true, + ], + ]; + + /** + * @var array> + */ + protected array $_keyMap = [ + 'underscored' => [ + 'title' => 'title', + 'nested' => 'nested', + 'items' => 'items', + ], + 'dashed' => [ + 'title' => 'title', + 'nested' => 'nested', + 'items' => 'items', + ], + ]; + + /** + * Optimized setFromArrayFast - handles lazy fields. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void + { + if (isset($data['title'])) { + $this->title = $data['title']; + $this->_touchedFields[self::FIELD_TITLE] = true; + } + if (array_key_exists('nested', $data)) { + $this->_lazyData['nested'] = $data['nested']; + $this->_touchedFields[self::FIELD_NESTED] = true; + } + if (array_key_exists('items', $data)) { + $this->_lazyData['items'] = $data['items']; + $this->_touchedFields[self::FIELD_ITEMS] = true; + } + } + + /** + * @param array $data + * + * @throws \InvalidArgumentException + * + * @return void + */ + protected function validateFieldNames(array $data): void + { + $diff = array_diff(array_keys($data), array_keys($this->_metadata)); + if ($diff) { + throw new InvalidArgumentException('Unexpected fields: ' . implode(', ', $diff)); + } + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(?string $title): self + { + $this->title = $title; + $this->_touchedFields[self::FIELD_TITLE] = true; + + return $this; + } + + public function hasTitle(): bool + { + return $this->title !== null; + } + + /** + * Lazy getter - hydrates from raw data on first access. + */ + public function getNested(): ?SimpleDto + { + if (array_key_exists('nested', $this->_lazyData)) { + $value = $this->_lazyData['nested']; + if (is_array($value)) { + $this->nested = new SimpleDto($value); + } else { + $this->nested = $value; + } + unset($this->_lazyData['nested']); + } + + return $this->nested; + } + + public function setNested(?SimpleDto $nested): self + { + unset($this->_lazyData['nested']); + $this->nested = $nested; + $this->_touchedFields[self::FIELD_NESTED] = true; + + return $this; + } + + public function hasNested(): bool + { + return array_key_exists('nested', $this->_lazyData) || $this->nested !== null; + } + + /** + * Lazy getter - hydrates collection from raw data on first access. + * + * @return array<\PhpCollective\Dto\Test\TestDto\SimpleDto>|null + */ + public function getItems(): ?array + { + if (array_key_exists('items', $this->_lazyData)) { + $value = $this->_lazyData['items']; + if (is_array($value)) { + $this->items = []; + foreach ($value as $k => $v) { + $this->items[$k] = is_array($v) ? new SimpleDto($v) : $v; + } + } else { + $this->items = $value; + } + unset($this->_lazyData['items']); + } + + return $this->items; + } + + /** + * @param array<\PhpCollective\Dto\Test\TestDto\SimpleDto>|null $items + * + * @return $this + */ + public function setItems(?array $items) + { + unset($this->_lazyData['items']); + $this->items = $items; + $this->_touchedFields[self::FIELD_ITEMS] = true; + + return $this; + } + + public function hasItems(): bool + { + return array_key_exists('items', $this->_lazyData) || $this->items !== null; + } + + /** + * Optimized toArray. + * + * @param string|null $type + * @param array|null $fields + * @param bool $touched + * + * @return array + */ + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array + { + if ($type === null && $fields === null && !$touched) { + return $this->toArrayFast(); + } + + return $this->_toArrayInternal($type, $fields, $touched); + } + + /** + * @return array + */ + protected function toArrayFast(): array + { + $result = []; + $result['title'] = $this->title; + + if (array_key_exists('nested', $this->_lazyData)) { + $result['nested'] = $this->_lazyData['nested']; + } else { + $result['nested'] = $this->nested !== null ? $this->nested->toArray() : null; + } + + if (array_key_exists('items', $this->_lazyData)) { + $result['items'] = $this->_lazyData['items']; + } else { + if ($this->items !== null) { + $result['items'] = []; + foreach ($this->items as $k => $v) { + $result['items'][$k] = $v->toArray(); + } + } else { + $result['items'] = null; + } + } + + return $result; + } + + /** + * @param array $data + * @param bool $ignoreMissing + * @param string|null $type + * + * @return static + */ + public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static + { + return new static($data, $ignoreMissing, $type); + } +} diff --git a/tests/TestDto/ReadonlyDto.php b/tests/TestDto/ReadonlyDto.php new file mode 100644 index 0000000..1c3ec2e --- /dev/null +++ b/tests/TestDto/ReadonlyDto.php @@ -0,0 +1,283 @@ +> + */ + protected array $_metadata = [ + 'name' => [ + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => false, + 'collectionType' => null, + 'singularType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'isClass' => false, + 'enum' => null, + 'minLength' => null, + 'maxLength' => null, + 'min' => null, + 'max' => null, + 'pattern' => null, + 'lazy' => false, + ], + 'age' => [ + 'type' => 'int', + 'required' => false, + 'defaultValue' => null, + 'dto' => false, + 'collectionType' => null, + 'singularType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'isClass' => false, + 'enum' => null, + 'minLength' => null, + 'maxLength' => null, + 'min' => null, + 'max' => null, + 'pattern' => null, + 'lazy' => false, + ], + 'active' => [ + 'type' => 'bool', + 'required' => false, + 'defaultValue' => null, + 'dto' => false, + 'collectionType' => null, + 'singularType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'isClass' => false, + 'enum' => null, + 'minLength' => null, + 'maxLength' => null, + 'min' => null, + 'max' => null, + 'pattern' => null, + 'lazy' => false, + ], + ]; + + /** + * @var array> + */ + protected array $_keyMap = [ + 'underscored' => [ + 'name' => 'name', + 'age' => 'age', + 'active' => 'active', + ], + 'dashed' => [ + 'name' => 'name', + 'age' => 'age', + 'active' => 'active', + ], + ]; + + /** + * Optimized setFromArrayFast - direct assignment during constructor. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void + { + if (isset($data['name'])) { + $this->name = $data['name']; + $this->_touchedFields[self::FIELD_NAME] = true; + } + if (isset($data['age'])) { + $this->age = (int)$data['age']; + $this->_touchedFields[self::FIELD_AGE] = true; + } + if (isset($data['active'])) { + $this->active = (bool)$data['active']; + $this->_touchedFields[self::FIELD_ACTIVE] = true; + } + } + + /** + * @param array $data + * + * @throws \InvalidArgumentException + * + * @return void + */ + protected function validateFieldNames(array $data): void + { + $diff = array_diff(array_keys($data), array_keys($this->_metadata)); + if ($diff) { + throw new InvalidArgumentException('Unexpected fields: ' . implode(', ', $diff)); + } + } + + /** + * Initialize unset nullable readonly properties to null. + * + * @return $this + */ + protected function setDefaults() + { + if (!isset($this->name)) { + $this->name = null; + } + if (!isset($this->age)) { + $this->age = null; + } + if (!isset($this->active)) { + $this->active = null; + } + + return $this; + } + + /** + * @return array + */ + protected function toArrayFast(): array + { + return [ + 'name' => $this->name, + 'age' => $this->age, + 'active' => $this->active, + ]; + } + + /** + * @param string|null $type + * @param array|null $fields + * @param bool $touched + * + * @return array + */ + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array + { + if ($type === null && $fields === null && !$touched) { + return $this->toArrayFast(); + } + + return $this->_toArrayInternal($type, $fields, $touched); + } + + public function getName(): ?string + { + return $this->name; + } + + /** + * @return static + */ + public function withName(?string $name) + { + $data = $this->toArray(); + $data['name'] = $name; + + return new static($data); + } + + public function hasName(): bool + { + return $this->name !== null; + } + + public function getAge(): ?int + { + return $this->age; + } + + /** + * @return static + */ + public function withAge(?int $age) + { + $data = $this->toArray(); + $data['age'] = $age; + + return new static($data); + } + + public function hasAge(): bool + { + return $this->age !== null; + } + + public function getActive(): ?bool + { + return $this->active; + } + + /** + * @return static + */ + public function withActive(?bool $active) + { + $data = $this->toArray(); + $data['active'] = $active; + + return new static($data); + } + + public function hasActive(): bool + { + return $this->active !== null; + } + + /** + * @param array $data + * @param bool $ignoreMissing + * @param string|null $type + * + * @return static + */ + public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static + { + return new static($data, $ignoreMissing, $type); + } +} diff --git a/tests/TestDto/ValidatedDto.php b/tests/TestDto/ValidatedDto.php new file mode 100644 index 0000000..2b90dc8 --- /dev/null +++ b/tests/TestDto/ValidatedDto.php @@ -0,0 +1,237 @@ +> + */ + protected array $_metadata = [ + 'name' => [ + 'type' => 'string', + 'required' => true, + 'defaultValue' => null, + 'dto' => false, + 'collectionType' => null, + 'singularType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'isClass' => false, + 'enum' => null, + 'minLength' => 2, + 'maxLength' => 50, + 'min' => null, + 'max' => null, + 'pattern' => null, + ], + 'email' => [ + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => false, + 'collectionType' => null, + 'singularType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'isClass' => false, + 'enum' => null, + 'minLength' => null, + 'maxLength' => null, + 'min' => null, + 'max' => null, + 'pattern' => '/^[^@]+@[^@]+$/', + ], + 'age' => [ + 'type' => 'int', + 'required' => false, + 'defaultValue' => null, + 'dto' => false, + 'collectionType' => null, + 'singularType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'isClass' => false, + 'enum' => null, + 'minLength' => null, + 'maxLength' => null, + 'min' => 0, + 'max' => 150, + 'pattern' => null, + ], + 'score' => [ + 'type' => 'float', + 'required' => false, + 'defaultValue' => null, + 'dto' => false, + 'collectionType' => null, + 'singularType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'isClass' => false, + 'enum' => null, + 'minLength' => null, + 'maxLength' => null, + 'min' => 0.0, + 'max' => 100.0, + 'pattern' => null, + ], + ]; + + /** + * @var array> + */ + protected array $_keyMap = [ + 'underscored' => [ + 'name' => 'name', + 'email' => 'email', + 'age' => 'age', + 'score' => 'score', + ], + 'dashed' => [ + 'name' => 'name', + 'email' => 'email', + 'age' => 'age', + 'score' => 'score', + ], + ]; + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + $this->_touchedFields['name'] = true; + + return $this; + } + + public function hasName(): bool + { + return $this->name !== null; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): self + { + $this->email = $email; + $this->_touchedFields['email'] = true; + + return $this; + } + + public function hasEmail(): bool + { + return $this->email !== null; + } + + public function getAge(): ?int + { + return $this->age; + } + + public function setAge(?int $age): self + { + $this->age = $age; + $this->_touchedFields['age'] = true; + + return $this; + } + + public function hasAge(): bool + { + return $this->age !== null; + } + + public function getScore(): ?float + { + return $this->score; + } + + public function setScore(?float $score): self + { + $this->score = $score; + $this->_touchedFields['score'] = true; + + return $this; + } + + public function hasScore(): bool + { + return $this->score !== null; + } + + /** + * @param string|null $type + * @param array|null $fields + * @param bool $touched + * + * @return array + */ + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array + { + return $this->_toArrayInternal($type, $fields, $touched); + } + + /** + * @param array $data + * @param bool $ignoreMissing + * @param string|null $type + * + * @return static + */ + public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static + { + return static::_createFromArrayInternal($data, $ignoreMissing, $type); + } +}