diff --git a/docs/guide/performance.md b/docs/guide/performance.md index 844203f..fdaea6d 100644 --- a/docs/guide/performance.md +++ b/docs/guide/performance.md @@ -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 @@ -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 @@ -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: @@ -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 @@ -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 @@ -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: @@ -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: diff --git a/src/Generator/TransformCompiler.php b/src/Generator/TransformCompiler.php new file mode 100644 index 0000000..8096346 --- /dev/null +++ b/src/Generator/TransformCompiler.php @@ -0,0 +1,84 @@ +[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 . ')'; + } +} diff --git a/src/Generator/TwigRenderer.php b/src/Generator/TwigRenderer.php index ff9a69d..49fc6e4 100644 --- a/src/Generator/TwigRenderer.php +++ b/src/Generator/TwigRenderer.php @@ -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])); } /** @@ -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 $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. * diff --git a/templates/dto.twig b/templates/dto.twig index 6d3bbe9..6a7cdd3 100644 --- a/templates/dto.twig +++ b/templates/dto.twig @@ -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 %} } diff --git a/templates/element/optimizations.twig b/templates/element/optimizations.twig index db6e220..0aa5f7a 100644 --- a/templates/element/optimizations.twig +++ b/templates/element/optimizations.twig @@ -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 %} @@ -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); @@ -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)) { @@ -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); @@ -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' %} @@ -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 %} @@ -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); @@ -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; @@ -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 %} @@ -261,13 +261,13 @@ {% 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 %} @@ -275,13 +275,13 @@ {% 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 %} @@ -290,13 +290,13 @@ {% 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 %} @@ -304,20 +304,20 @@ {% 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 %} diff --git a/tests/Generator/TransformCompilerTest.php b/tests/Generator/TransformCompilerTest.php new file mode 100644 index 0000000..415d74a --- /dev/null +++ b/tests/Generator/TransformCompilerTest.php @@ -0,0 +1,102 @@ +assertSame($expected, TransformCompiler::compile($callable, $expr, $guardNull, true)); + } + + public function testCompileWithoutStrictTypesFallsBack(): void + { + $callable = TransformHelper::class . '::normalizeEmail'; + $expected = '$this->transformValue(\'' . str_replace('\\', '\\\\', $callable) . '\', $value)'; + + $this->assertSame($expected, TransformCompiler::compile($callable, '$value', false, false)); + } + + /** + * @return array + */ + public static function compileProvider(): array + { + $helper = TransformHelper::class; + + return [ + 'null callable' => [null, '$value', false, '$value'], + 'empty callable' => ['', '$value', false, '$value'], + 'plain function' => ['trim', '$value', false, '\trim($value)'], + 'static method' => [ + $helper . '::normalizeEmail', + '$value', + false, + '\\' . $helper . '::normalizeEmail($value)', + ], + 'leading backslash' => [ + '\\' . $helper . '::normalizeEmail', + '$value', + false, + '\\' . $helper . '::normalizeEmail($value)', + ], + 'guard null true' => [ + $helper . '::normalizeEmail', + '$value', + true, + '($value === null ? null : \\' . $helper . '::normalizeEmail($value))', + ], + 'guard null false with non-simple expression' => [ + $helper . '::normalizeEmail', + '$this->value()', + false, + '\\' . $helper . '::normalizeEmail($this->value())', + ], + 'guard null true with non-simple expression falls back' => [ + $helper . '::normalizeEmail', + '$this->value()', + true, + '$this->transformValue(\'' . str_replace('\\', '\\\\', $helper) . '::normalizeEmail\', $this->value())', + ], + 'unresolvable callable falls back' => [ + 'App\\Transform\\Email::normalizeTypo', + '$value', + false, + '$this->transformValue(\'App\\\\Transform\\\\Email::normalizeTypo\', $value)', + ], + 'unresolvable function falls back' => [ + 'trimx', + '$value', + false, + '$this->transformValue(\'trimx\', $value)', + ], + 'injection-shaped function call' => [ + "foo(); echo 'x'", + '$value', + false, + '$this->transformValue(\'foo(); echo \\\'x\\\'\', $value)', + ], + 'injection-shaped static method' => [ + 'Foo::bar; //', + '$value', + false, + '$this->transformValue(\'Foo::bar; //\', $value)', + ], + 'trailing whitespace' => ['trim ', '$value', false, '$this->transformValue(\'trim \', $value)'], + 'fallback escapes backslashes and quotes' => [ + "Foo\\Bar::baz'qux", + '$value', + false, + '$this->transformValue(\'Foo\\\\Bar::baz\\\'qux\', $value)', + ], + ]; + } +} diff --git a/tests/Generator/TwigRendererTest.php b/tests/Generator/TwigRendererTest.php index 14e3ba1..fcb4cab 100644 --- a/tests/Generator/TwigRendererTest.php +++ b/tests/Generator/TwigRendererTest.php @@ -5,6 +5,7 @@ namespace PhpCollective\Dto\Test\Generator; use PhpCollective\Dto\Generator\TwigRenderer; +use PhpCollective\Dto\Test\TestDto\TransformHelper; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -87,6 +88,73 @@ public function testUnderscorePrefixedFieldGeneratesCorrectMethodNames(): void $this->assertStringNotContainsString('with_joinData', $withOutput); } + public function testOptimizationsInlineTransformCalls(): void + { + $helper = TransformHelper::class; + $this->renderer->set(self::optimizationsData($helper . '::normalizeEmail', $helper . '::maskEmail')); + + $output = $this->renderer->generate('element/optimizations'); + + $this->assertStringContainsString('$value = \\' . $helper . '::normalizeEmail($value);', $output); + $this->assertStringContainsString('\\' . $helper . '::maskEmail($this->email)', $output); + $this->assertStringNotContainsString('transformValue(', $output); + } + + public function testOptimizationsDoNotInlineWithoutStrictTypes(): void + { + $helper = TransformHelper::class; + $this->renderer->set(self::optimizationsData($helper . '::normalizeEmail', $helper . '::maskEmail', false)); + + $output = $this->renderer->generate('element/optimizations'); + + $this->assertStringContainsString('$this->transformValue(', $output); + $this->assertStringNotContainsString('$value = \\' . $helper . '::normalizeEmail($value);', $output); + } + + public function testOptimizationsFallBackToRuntimeTransformForUnsafeCallable(): void + { + $this->renderer->set(self::optimizationsData("foo(); echo 'x'", null)); + + $output = $this->renderer->generate('element/optimizations'); + + $this->assertStringContainsString('$this->transformValue(\'foo(); echo \\\'x\\\'\', $value)', $output); + } + + /** + * @param string|null $transformFrom + * @param string|null $transformTo + * @param bool $strictTypes + * + * @return array + */ + private static function optimizationsData(?string $transformFrom, ?string $transformTo, bool $strictTypes = true): array + { + return [ + 'immutable' => false, + 'strictTypes' => $strictTypes, + 'fields' => [ + [ + 'name' => 'email', + 'type' => 'string', + 'nullable' => true, + 'typeHint' => 'string', + 'nullableTypeHint' => '?string', + 'docBlockType' => 'string', + 'collection' => false, + 'collectionType' => null, + 'dto' => false, + 'isClass' => false, + 'enum' => null, + 'factory' => null, + 'serialize' => null, + 'mapTo' => null, + 'transformFrom' => $transformFrom, + 'transformTo' => $transformTo, + ], + ], + ]; + } + public function testRegularFieldGeneratesCorrectMethodNames(): void { // Verify regular fields still work correctly diff --git a/tests/TestDto/TransformDto.php b/tests/TestDto/TransformDto.php index 09aedc1..78c7f7a 100644 --- a/tests/TestDto/TransformDto.php +++ b/tests/TestDto/TransformDto.php @@ -8,6 +8,11 @@ class TransformDto extends AbstractDto { + /** + * @var bool + */ + protected const HAS_FAST_PATH = true; + protected ?string $email = null; /** @@ -62,6 +67,32 @@ public function hasEmail(): bool return $this->email !== null; } + /** + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void + { + if (isset($data['email'])) { + /** @var string|null $value */ + $value = $data['email']; + $value = TransformHelper::normalizeEmail($value); + $this->email = $value; + $this->_touchedFields['email'] = true; + } + } + + /** + * @return array + */ + protected function toArrayFast(): array + { + return [ + 'email' => ($this->email === null ? null : TransformHelper::maskEmail($this->email)), + ]; + } + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array { return $this->_toArrayInternal($type, $fields, $touched);