diff --git a/README.md b/README.md index 6dc76c8..ddff826 100644 --- a/README.md +++ b/README.md @@ -122,10 +122,13 @@ Reading in a template (e.g., Twig, Blade, or plain PHP): ## Features - **Types**: `int`, `float`, `string`, `bool`, `array`, `mixed`, DTOs, classes, enums -- **Union types**: `int|string`, `int|float|string` (PHP 8.0+) +- **Union types**: `int|string`, `int|float|string` - **Collections**: `'type' => 'Item[]', 'collection' => true` with add/remove/get/has methods - **Associative collections**: keyed access with `'associative' => true` - **Immutable DTOs**: `'immutable' => true` with `with*()` methods +- **Readonly properties**: `public readonly` with direct property access +- **Validation rules**: built-in `minLength`, `maxLength`, `min`, `max`, `pattern` constraints +- **Lazy properties**: deferred DTO/collection hydration with `asLazy()` - **Default values**: `'defaultValue' => 0` - **Required fields**: `'required' => true` - **Deprecations**: `'deprecated' => 'Use newField instead'` diff --git a/docs/ConfigBuilder.md b/docs/ConfigBuilder.md index b4cdb4f..24ef073 100644 --- a/docs/ConfigBuilder.md +++ b/docs/ConfigBuilder.md @@ -487,11 +487,59 @@ Dto::create('Order')->fields( ) ``` -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. +### How It Works -This is useful for large nested structures where not all fields are always accessed. +Lazy fields store raw array data during construction in an internal `$_lazyData` property. +When the getter is called for the first time, the raw data is hydrated into the DTO/collection +and cached. Subsequent getter calls return the cached instance. + +```php +$order = new OrderDto([ + 'id' => 1, + 'customer' => ['name' => 'John', 'email' => 'john@example.com'], + 'items' => [ + ['product' => 'Widget', 'quantity' => 2], + ['product' => 'Gadget', 'quantity' => 1], + ], +]); + +// No CustomerDto or OrderItemDto objects created yet + +$customer = $order->getCustomer(); // Now CustomerDto is hydrated +$items = $order->getItems(); // Now OrderItemDto[] is hydrated +``` + +### toArray() Behavior + +If `toArray()` is called before the getter, the raw data is returned directly — no object +creation occurs. This is useful for pass-through scenarios where DTOs are used for validation +and transport without accessing nested fields: + +```php +$order = new OrderDto($apiResponse); +$json = json_encode($order->toArray()); // No nested DTOs created +``` + +### When to Use Lazy Properties + +- **Large nested structures** where not all fields are always accessed +- **API pass-through** where data is validated and forwarded without deep inspection +- **Performance-critical paths** where avoiding unnecessary object creation matters +- **Deep object graphs** where eager hydration would create many unused objects + +### Mixing Lazy and Eager Fields + +You can mix lazy and eager fields in the same DTO: + +```php +Dto::create('Order')->fields( + Field::int('id')->required(), + Field::string('status')->required(), // Eager - always hydrated + Field::dto('customer', 'Customer')->asLazy(), // Lazy - on-demand + Field::dto('summary', 'OrderSummary'), // Eager - always hydrated + Field::collection('items', 'OrderItem')->singular('item')->asLazy(), // Lazy +) +``` ## Readonly Properties @@ -507,8 +555,47 @@ Dto::create('Config')->readonlyProperties()->fields( 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`) +- 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. + +### Usage Example + +```php +$config = new ConfigDto(['host' => 'localhost', 'port' => 3306]); + +// Direct property access +echo $config->host; // "localhost" +echo $config->port; // 3306 + +// Getters also work +echo $config->getHost(); // "localhost" + +// Attempting to modify throws \Error at runtime +$config->host = 'other'; // Error: Cannot modify readonly property + +// Use with*() methods to create modified copies +$newConfig = $config->withPort(5432); +echo $config->port; // 3306 (original unchanged) +echo $newConfig->port; // 5432 +``` + +### Readonly vs Immutable: When to Use Which + +| Feature | `immutable()` | `readonlyProperties()` | +|---------|---------------|------------------------| +| Property visibility | `protected` | `public readonly` | +| Property access | `$dto->getName()` | `$dto->name` or `$dto->getName()` | +| Modification protection | Convention (no setters) | Language-enforced | +| `with*()` implementation | Clone + set property | Reconstruct from array | +| API consistency | Same as mutable DTOs | Different from mutable DTOs | + +**Choose `immutable()`** when: +- You want consistent getter-based API across all DTOs (mutable and immutable) +- You're migrating between mutable and immutable and want minimal code changes + +**Choose `readonlyProperties()`** when: +- You prefer shorter syntax with direct property access +- You want IDE/static analysis to catch accidental mutation attempts diff --git a/docs/Examples.md b/docs/Examples.md index 042367f..7649cf4 100644 --- a/docs/Examples.md +++ b/docs/Examples.md @@ -591,3 +591,215 @@ echo json_encode($dto->toArray(), JSON_PRETTY_PRINT); // Or use __toString which returns JSON echo $dto; // Calls serialize() internally ``` + +## Validation Rules + +Built-in validation rules provide field-level constraints checked during construction. + +### Basic Validation + +```php +// Configuration +Dto::create('User')->fields( + Field::string('username')->required()->minLength(3)->maxLength(20), + Field::string('email')->required()->pattern('/^[^@]+@[^@]+\.[^@]+$/'), + Field::int('age')->min(0)->max(150), +) +``` + +```php +// Valid - passes all rules +$user = new UserDto([ + 'username' => 'johndoe', + 'email' => 'john@example.com', + 'age' => 25, +]); + +// Invalid - throws InvalidArgumentException +$user = new UserDto([ + 'username' => 'jo', // Too short (minLength: 3) + 'email' => 'john@example.com', +]); +// Exception: Field 'username' must be at least 3 characters + +// Invalid email pattern +$user = new UserDto([ + 'username' => 'johndoe', + 'email' => 'not-an-email', +]); +// Exception: Field 'email' does not match required pattern +``` + +### Nullable Fields Skip Validation + +```php +Dto::create('Profile')->fields( + Field::string('bio')->maxLength(500), // Optional field + Field::int('followers')->min(0), +) +``` + +```php +// Null values skip validation +$profile = new ProfileDto(['bio' => null, 'followers' => null]); // OK + +// Non-null values are validated +$profile = new ProfileDto(['bio' => str_repeat('x', 501)]); +// Exception: Field 'bio' must be at most 500 characters +``` + +### Extracting Validation Rules + +Use `validationRules()` to get framework-agnostic rules for integration with validators: + +```php +$dto = new UserDto(['username' => 'test', 'email' => 'test@example.com']); +$rules = $dto->validationRules(); + +// Returns: +// [ +// 'username' => ['required' => true, 'minLength' => 3, 'maxLength' => 20], +// 'email' => ['required' => true, 'pattern' => '/^[^@]+@[^@]+\.[^@]+$/'], +// 'age' => ['min' => 0, 'max' => 150], +// ] + +// Use with framework validators +$validator = new FrameworkValidator($rules); +``` + +## Lazy Loading + +Defer nested DTO/collection hydration for performance optimization. + +### Basic Lazy Loading + +```php +// Configuration +Dto::create('Order')->fields( + Field::int('id')->required(), + Field::string('status'), + Field::dto('customer', 'Customer')->asLazy(), + Field::collection('items', 'OrderItem')->singular('item')->asLazy(), +) +``` + +```php +// Create order from API response +$order = new OrderDto([ + 'id' => 123, + 'status' => 'pending', + 'customer' => ['name' => 'John', 'email' => 'john@example.com'], + 'items' => [ + ['product' => 'Widget', 'quantity' => 2, 'price' => 29.99], + ['product' => 'Gadget', 'quantity' => 1, 'price' => 49.99], + ], +]); + +// At this point: no CustomerDto or OrderItemDto objects exist yet + +// Access triggers hydration +$customer = $order->getCustomer(); // CustomerDto created now +echo $customer->getName(); // "John" +``` + +### Pass-Through Optimization + +When data is forwarded without deep inspection, lazy fields avoid unnecessary object creation: + +```php +// API gateway scenario +$orderData = $this->fetchFromUpstreamApi(); +$order = new OrderDto($orderData); + +// Forward to downstream service - no nested objects created +$this->downstreamApi->send($order->toArray()); +``` + +### Checking Lazy State + +```php +// Access triggers hydration - subsequent calls return cached instance +$items1 = $order->getItems(); // Hydrated +$items2 = $order->getItems(); // Same instance returned +assert($items1 === $items2); +``` + +## Readonly Properties + +Use `readonly` properties for language-level immutability with direct property access. + +### Basic Readonly DTO + +```php +// Configuration +Dto::create('DatabaseConfig')->readonlyProperties()->fields( + Field::string('host')->required(), + Field::int('port')->default(3306), + Field::string('database')->required(), + Field::string('username')->required(), + Field::string('password'), +) +``` + +```php +$config = new DatabaseConfigDto([ + 'host' => 'localhost', + 'database' => 'myapp', + 'username' => 'root', +]); + +// Direct property access +echo $config->host; // "localhost" +echo $config->port; // 3306 (default) +echo $config->database; // "myapp" + +// Getters also work +echo $config->getHost(); // "localhost" + +// Modification throws Error +$config->host = 'other'; // Error: Cannot modify readonly property +``` + +### Creating Modified Copies + +```php +$devConfig = new DatabaseConfigDto([ + 'host' => 'localhost', + 'database' => 'myapp_dev', + 'username' => 'dev', +]); + +// Create production config based on dev +$prodConfig = $devConfig + ->withHost('prod-db.example.com') + ->withDatabase('myapp_prod') + ->withUsername('app_user') + ->withPassword('secret'); + +// Original unchanged +echo $devConfig->host; // "localhost" +echo $prodConfig->host; // "prod-db.example.com" +``` + +### Readonly vs Immutable Comparison + +```php +// Immutable DTO - getter-based access (consistent with mutable DTOs) +Dto::immutable('Event')->fields( + Field::string('name')->required(), + Field::string('payload'), +) + +$event = new EventDto(['name' => 'user.created', 'payload' => '{}']); +echo $event->getName(); // Access via getter + +// Readonly DTO - direct property access +Dto::create('Event')->readonlyProperties()->fields( + Field::string('name')->required(), + Field::string('payload'), +) + +$event = new EventDto(['name' => 'user.created', 'payload' => '{}']); +echo $event->name; // Direct access - shorter syntax +echo $event->getName(); // Getter also works +```