Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions docs/guide/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,34 @@ opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; Disable in production
```

### 2. Prefer touchedToArray() for Partial Data
### 2. Enable strictTypes so Transforms Get Compiled

Fields using `transformFrom` / `transformTo` are compiled into a direct call in the generated
fast-path methods:

``` php
// generated with strictTypes enabled
$value = \App\Transform\Email::normalize($value);

// generated without it
$value = $this->transformValue('App\Transform\Email::normalize', $value);
```

The runtime form pays a method call, a null check, an `is_callable()` lookup and a dynamic
invocation for every transformed field on every hydration and serialization - roughly 4x the
cost of the direct call.

Inlining requires `strictTypes`, because an inlined call takes its argument coercion from the
generated file rather than from the library, and dropping strict types there would silently
change how scalar type hints on your transform callables behave.

::: tip
A transform that cannot be resolved while generating (typo, class not autoloadable at bake
time) keeps the runtime form on purpose, so the error still surfaces as
`InvalidArgumentException` instead of a raw `Error`.
:::

### 3. Prefer touchedToArray() for Partial Data

```php
// Slower: serializes all fields
Expand All @@ -65,7 +92,7 @@ $data = $dto->touchedToArray();

Especially important for DTOs with many optional fields.

### 3. Use ignoreMissing for Partial Input
### 4. Use ignoreMissing for Partial Input

```php
// Slower: validates every field exists
Expand All @@ -75,7 +102,7 @@ $dto = new UserDto($partialData);
$dto = new UserDto($partialData, ignoreMissing: true);
```

### 4. Batch Operations
### 5. Batch Operations

When processing many items, create DTOs in batches:

Expand All @@ -89,7 +116,7 @@ foreach ($items as $item) {
$dtos = array_map(fn($item) => new ItemDto($item), $items);
```

### 5. Avoid Unnecessary Cloning
### 6. Avoid Unnecessary Cloning

```php
// Unnecessary clone
Expand All @@ -103,7 +130,7 @@ $original->setName('New');
$modified = $original->withName('New');
```

### 6. Use Appropriate Collection Types
### 7. Use Appropriate Collection Types

```php
// ArrayObject: Good for iteration, modification
Expand All @@ -115,7 +142,7 @@ $modified = $original->withName('New');

For read-heavy workloads, array collections are slightly faster.

### 7. Minimize Nested Depth
### 8. Minimize Nested Depth

Deeply nested DTOs have cumulative overhead:

Expand All @@ -127,7 +154,7 @@ $order->getCustomer()->getAddress()->getCity()->getName();
$order->getCustomerCity(); // Computed during creation
```

### 8. Use Built-in Lazy DTO Fields When Nested Data Is Optional
### 9. Use Built-in Lazy DTO Fields When Nested Data Is Optional

php-collective/dto supports lazy DTO and collection fields via `asLazy()`, which lets you defer nested hydration until the data is actually accessed:

Expand Down
84 changes: 84 additions & 0 deletions src/Generator/TransformCompiler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace PhpCollective\Dto\Generator;

/**
* Compiles schema transform callables into generated PHP expressions.
*/
class TransformCompiler
{
/**
* @var string
*/
protected const CALLABLE_PATTERN = '/^\\\\?[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*(\\\\[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*)*(::[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*)?$/';

/**
* @var string
*/
protected const SIMPLE_EXPR_PATTERN = '/^\$[A-Za-z_]\w*(\[[^\[\]]+\]|->[A-Za-z_]\w*)*$/';

/**
* Compile a transform callable into a generated PHP expression.
*
* @param string|null $callable Transform callable from the schema
* @param string $expr Generated PHP expression to transform
* @param bool $guardNull Whether null values must bypass the transform
* @param bool $strictTypes Whether the generated file declares strict types
*
* @return string
*/
public static function compile(?string $callable, string $expr, bool $guardNull = false, bool $strictTypes = false): string
{
if ($callable === null || $callable === '') {
return $expr;
}

// An inlined call takes its argument coercion from the generated file, while the
// runtime dispatch takes it from Dto.php, which is strict. Inline only when the
// generated file is strict as well, so scalar type hints keep raising TypeError.
if (!$strictTypes) {
return self::fallback($callable, $expr);
}

if (!preg_match(self::CALLABLE_PATTERN, $callable)) {
return self::fallback($callable, $expr);
}

// A guarded, non-simple expression cannot be inlined without evaluating it twice, so
// fall back before resolving the callable to avoid a needless autoload.
if ($guardNull && !preg_match(self::SIMPLE_EXPR_PATTERN, $expr)) {
return self::fallback($callable, $expr);
}

// Only inline what resolves while generating. Anything unresolved keeps the runtime
// dispatch so a typo in the schema still surfaces as InvalidArgumentException instead
// of a raw Error from the generated code.
if (!is_callable($callable)) {
return self::fallback($callable, $expr);
}

$call = '\\' . ltrim($callable, '\\') . '(' . $expr . ')';

if ($guardNull) {
// Parenthesized so the expression stays safe in any surrounding context.
return '(' . $expr . ' === null ? null : ' . $call . ')';
}

return $call;
}

/**
* Compile the existing runtime transform dispatch.
*
* @param string $callable Transform callable from the schema
* @param string $expr Generated PHP expression to transform
*
* @return string
*/
protected static function fallback(string $callable, string $expr): string
{
return '$this->transformValue(\'' . addcslashes($callable, "'\\") . '\', ' . $expr . ')';
}
}
19 changes: 19 additions & 0 deletions src/Generator/TwigRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ protected function registerFilters(): void
protected function registerFunctions(): void
{
$this->twig->addFunction(new TwigFunction('getCollectionAdapter', [$this, 'getCollectionAdapter']));
$this->twig->addFunction(new TwigFunction('transformExpr', [$this, 'transformExpr'], ['needs_context' => true]));
}

/**
Expand Down Expand Up @@ -113,6 +114,24 @@ public function getCollectionAdapter(string $collectionType): CollectionAdapterI
return CollectionAdapterRegistry::getOrDefault($collectionType);
}

/**
* Compile a field transform into a generated PHP expression.
*
* Reads `strictTypes` from the render context because inlining is only safe in a
* generated file that declares strict types.
*
* @param array<string, mixed> $context
* @param string|null $callable Transform callable from the schema
* @param string $expr Generated PHP expression to transform
* @param bool $guardNull Whether null values must bypass the transform
*
* @return string
*/
public function transformExpr(array $context, ?string $callable, string $expr, bool $guardNull = false): string
{
return TransformCompiler::compile($callable, $expr, $guardNull, (bool)($context['strictTypes'] ?? false));
}

/**
* Set global configuration variables.
*
Expand Down
2 changes: 1 addition & 1 deletion templates/dto.twig
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class {{ className }} extends {% if extends starts with '\\' %}{{ extends[1:] |
{% 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, 'readonlyProperties': readonlyProperties|default(false)} only %}
{% include 'element/optimizations.twig' with {'fields': fields, 'immutable': immutable, 'readonlyProperties': readonlyProperties|default(false), 'strictTypes': strictTypes|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 %}
}
40 changes: 20 additions & 20 deletions templates/element/optimizations.twig
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
{% if field.lazy|default(false) %}
if (array_key_exists('{{ field.name }}', $data)) {
{% if field.transformFrom %}
$this->_lazyData['{{ field.name }}'] = $this->transformValue('{{ field.transformFrom }}', $data['{{ field.name }}']);
$this->_lazyData['{{ field.name }}'] = {{ transformExpr(field.transformFrom, "$data['" ~ field.name ~ "']", true)|raw }};
{% else %}
$this->_lazyData['{{ field.name }}'] = $data['{{ field.name }}'];
{% endif %}
Expand All @@ -35,7 +35,7 @@
if (isset($data['{{ field.name }}'])) {
$value = $data['{{ field.name }}'];
{% if field.transformFrom %}
$value = $this->transformValue('{{ field.transformFrom }}', $value);
$value = {{ transformExpr(field.transformFrom, '$value', false)|raw }};
{% endif %}
if (is_array($value)) {
$value = new {{ field.typeHint }}($value);
Expand All @@ -52,7 +52,7 @@
$items = $data['{{ field.name }}'];
foreach ($items as $key => $item) {
{% if field.transformFrom %}
$item = $this->transformValue('{{ field.transformFrom }}', $item);
$item = {{ transformExpr(field.transformFrom, '$item', true)|raw }};
{% endif %}
{% if field.singularClass is defined and field.singularClass %}
if (is_array($item)) {
Expand Down Expand Up @@ -82,7 +82,7 @@
$items = $data['{{ field.name }}'];
foreach ($items as $key => $item) {
{% if field.transformFrom %}
$item = $this->transformValue('{{ field.transformFrom }}', $item);
$item = {{ transformExpr(field.transformFrom, '$item', true)|raw }};
{% endif %}
if (is_array($item)) {
$item = new {{ field.singularClass }}($item);
Expand All @@ -96,7 +96,7 @@
if (isset($data['{{ field.name }}'])) {
$value = $data['{{ field.name }}'];
{% if field.transformFrom %}
$value = $this->transformValue('{{ field.transformFrom }}', $value);
$value = {{ transformExpr(field.transformFrom, '$value', false)|raw }};
{% endif %}
if (!$value instanceof \UnitEnum) {
{% if field.enum == 'unit' %}
Expand All @@ -112,7 +112,7 @@
if (isset($data['{{ field.name }}'])) {
$value = $data['{{ field.name }}'];
{% if field.transformFrom %}
$value = $this->transformValue('{{ field.transformFrom }}', $value);
$value = {{ transformExpr(field.transformFrom, '$value', false)|raw }};
{% endif %}
if (!$value instanceof {{ field.typeHint }}) {
{% if '::' in field.factory %}
Expand All @@ -129,7 +129,7 @@
if (isset($data['{{ field.name }}'])) {
$value = $data['{{ field.name }}'];
{% if field.transformFrom %}
$value = $this->transformValue('{{ field.transformFrom }}', $value);
$value = {{ transformExpr(field.transformFrom, '$value', false)|raw }};
{% endif %}
if (!$value instanceof {{ field.typeHint }}) {
$value = new {{ field.typeHint }}($value);
Expand All @@ -142,7 +142,7 @@
/** @var {{ field.docBlockType ?? field.typeHint }}{% if field.nullable %}|null{% endif %} $value */
$value = $data['{{ field.name }}'];
{% if field.transformFrom %}
$value = $this->transformValue('{{ field.transformFrom }}', $value);
$value = {{ transformExpr(field.transformFrom, '$value', false)|raw }};
{% endif %}
$this->{{ field.name }} = $value;
$this->_touchedFields['{{ field.name }}'] = true;
Expand All @@ -163,20 +163,20 @@
{% set key = field.mapTo is not null ? field.mapTo : field.name %}
{% 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)),
'{{ key }}' => {{ transformExpr(field.transformTo, "$this->_lazyData['" ~ field.name ~ "'] ?? ($this->" ~ field.name ~ " !== null ? " ~ (field.dto ? "$this->" ~ field.name ~ "->toArray()" : (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 ~ ")" : "$this->" ~ field.name)) ~ " : null)", true)|raw }},
{% 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),
'{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ " !== null ? $this->" ~ field.name ~ "->toArray() : null", true)|raw }},
{% else %}
'{{ key }}' => $this->{{ field.name }} !== null ? $this->{{ field.name }}->toArray() : null,
{% endif %}
{% else %}
{% if field.transformTo %}
'{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}->toArray()),
'{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ "->toArray()", false)|raw }},
{% else %}
'{{ key }}' => $this->{{ field.name }}->toArray(),
{% endif %}
Expand Down Expand Up @@ -261,27 +261,27 @@
{% if field.nullable %}
{% if field.enum == 'unit' %}
{% if field.transformTo %}
'{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}?->name),
'{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ "?->name", true)|raw }},
{% else %}
'{{ key }}' => $this->{{ field.name }}?->name,
{% endif %}
{% else %}
{% if field.transformTo %}
'{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}?->value),
'{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ "?->value", true)|raw }},
{% else %}
'{{ key }}' => $this->{{ field.name }}?->value,
{% endif %}
{% endif %}
{% else %}
{% if field.enum == 'unit' %}
{% if field.transformTo %}
'{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}->name),
'{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ "->name", false)|raw }},
{% else %}
'{{ key }}' => $this->{{ field.name }}->name,
{% endif %}
{% else %}
{% if field.transformTo %}
'{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}->value),
'{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ "->value", false)|raw }},
{% else %}
'{{ key }}' => $this->{{ field.name }}->value,
{% endif %}
Expand All @@ -290,34 +290,34 @@
{% elseif field.isClass and field.serialize == 'string' %}
{% if field.nullable %}
{% if field.transformTo %}
'{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }} !== null ? (string)$this->{{ field.name }} : null),
'{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ " !== null ? (string)$this->" ~ field.name ~ " : null", true)|raw }},
{% else %}
'{{ key }}' => $this->{{ field.name }} !== null ? (string)$this->{{ field.name }} : null,
{% endif %}
{% else %}
{% if field.transformTo %}
'{{ key }}' => $this->transformValue('{{ field.transformTo }}', (string)$this->{{ field.name }}),
'{{ key }}' => {{ transformExpr(field.transformTo, "(string)$this->" ~ field.name, false)|raw }},
{% else %}
'{{ key }}' => (string)$this->{{ field.name }},
{% endif %}
{% endif %}
{% elseif field.isClass and (field.serialize == 'array' or field.serialize == 'FromArrayToArray') %}
{% if field.nullable %}
{% if field.transformTo %}
'{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }} !== null ? $this->{{ field.name }}->toArray() : null),
'{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ " !== null ? $this->" ~ field.name ~ "->toArray() : null", true)|raw }},
{% else %}
'{{ key }}' => $this->{{ field.name }} !== null ? $this->{{ field.name }}->toArray() : null,
{% endif %}
{% else %}
{% if field.transformTo %}
'{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}->toArray()),
'{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ "->toArray()", false)|raw }},
{% else %}
'{{ key }}' => $this->{{ field.name }}->toArray(),
{% endif %}
{% endif %}
{% else %}
{% if field.transformTo %}
'{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}),
'{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name, field.nullable)|raw }},
{% else %}
'{{ key }}' => $this->{{ field.name }},
{% endif %}
Expand Down
Loading
Loading