diff --git a/src/data/src/Concerns/IncludeableData.php b/src/data/src/Concerns/IncludeableData.php index 16ba0c36b..09607f4ea 100644 --- a/src/data/src/Concerns/IncludeableData.php +++ b/src/data/src/Concerns/IncludeableData.php @@ -11,22 +11,73 @@ trait IncludeableData { use ForwardsToPartialsDefinition; - protected ?PartialsDefinition $partialDefinitions = null; + /** + * Null before defaults are inspected, false when they are empty, or the mutable definition store. + */ + protected PartialsDefinition|false|null $partialDefinitions = null; + + /** + * Determine whether this object has partial definitions. + * + * @phpstan-impure + */ + public function hasPartialsDefinition(): bool + { + if ($this->partialDefinitions instanceof PartialsDefinition) { + return ! $this->partialDefinitions->isEmpty(); + } + + if ($this->partialDefinitions === false) { + return false; + } + + $includes = $this->includeProperties(); + $excludes = $this->excludeProperties(); + $only = $this->onlyProperties(); + $except = $this->exceptProperties(); + + if ($includes === [] && $excludes === [] && $only === [] && $except === []) { + $this->partialDefinitions = false; + + return false; + } + + $partialDefinitions = new PartialsDefinition; + $partialDefinitions->addDefaults('include', $includes); + $partialDefinitions->addDefaults('exclude', $excludes); + $partialDefinitions->addDefaults('only', $only); + $partialDefinitions->addDefaults('except', $except); + + if ($partialDefinitions->isEmpty()) { + $this->partialDefinitions = false; + + return false; + } + + $this->partialDefinitions = $partialDefinitions; + + return true; + } /** * Get the current partial definitions. */ public function getPartialsDefinition(): PartialsDefinition { - if ($this->partialDefinitions !== null) { + if ($this->partialDefinitions instanceof PartialsDefinition) { return $this->partialDefinitions; } + if ($this->partialDefinitions === null) { + // Initialize class-owned defaults before creating an empty store for explicit writes. + $this->hasPartialsDefinition(); + + if ($this->partialDefinitions instanceof PartialsDefinition) { + return $this->partialDefinitions; + } + } + $this->partialDefinitions = new PartialsDefinition; - $this->partialDefinitions->addDefaults('include', $this->includeProperties()); - $this->partialDefinitions->addDefaults('exclude', $this->excludeProperties()); - $this->partialDefinitions->addDefaults('only', $this->onlyProperties()); - $this->partialDefinitions->addDefaults('except', $this->exceptProperties()); return $this->partialDefinitions; } diff --git a/src/data/src/Contracts/IncludeableData.php b/src/data/src/Contracts/IncludeableData.php index ec3cab311..027b4e9a0 100644 --- a/src/data/src/Contracts/IncludeableData.php +++ b/src/data/src/Contracts/IncludeableData.php @@ -69,6 +69,11 @@ public function onlyWhen(string $only, bool|Closure $condition, bool $permanent */ public function exceptWhen(string $except, bool|Closure $condition, bool $permanent = false): object; + /** + * Determine whether this object has partial definitions. + */ + public function hasPartialsDefinition(): bool; + /** * Get the current partial definitions. */ diff --git a/src/data/src/Support/Factories/DataClassFactory.php b/src/data/src/Support/Factories/DataClassFactory.php index fe9cc5d30..9afcb1325 100644 --- a/src/data/src/Support/Factories/DataClassFactory.php +++ b/src/data/src/Support/Factories/DataClassFactory.php @@ -118,7 +118,10 @@ public function build(ReflectionClass $reflectionClass): DataClass $redirectRoute = $attributes->first(RedirectToRoute::class)?->newInstance(); $lifecycleMethods = $this->resolveLifecycleMethods($reflectionClass); $propertyMorphable = $reflectionClass->implementsInterface(PropertyMorphableData::class); - $transformationRecipe = $this->resolveTransformationRecipe($properties); + $transformable = $reflectionClass->implementsInterface(TransformableData::class); + $transformationRecipe = $transformable + ? $this->resolveTransformationRecipe($properties) + : null; $bulkCopyTransformation = $transformationRecipe !== null && $this->supportsBulkCopyTransformation($properties); @@ -136,7 +139,7 @@ public function build(ReflectionClass $reflectionClass): DataClass appendable: $reflectionClass->implementsInterface(AppendableData::class), includeable: $reflectionClass->implementsInterface(IncludeableData::class), responsable: $reflectionClass->implementsInterface(ResponsableData::class), - transformable: $reflectionClass->implementsInterface(TransformableData::class), + transformable: $transformable, validateable: $reflectionClass->implementsInterface(ValidateableData::class), wrappable: $reflectionClass->implementsInterface(WrappableData::class), emptyData: $reflectionClass->implementsInterface(EmptyData::class), diff --git a/src/data/src/Support/Transformation/DataTransformer.php b/src/data/src/Support/Transformation/DataTransformer.php index edead0d9b..7245dd73c 100644 --- a/src/data/src/Support/Transformation/DataTransformer.php +++ b/src/data/src/Support/Transformation/DataTransformer.php @@ -31,6 +31,7 @@ use Hypervel\Data\Support\DataConfig; use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\Lazy\DefaultLazy; +use Hypervel\Data\Support\Partials\PartialDefinition; use Hypervel\Data\Support\Types\Type; use Hypervel\Data\Support\Wrapping\WrapExecutionType; use Hypervel\Data\Transformers\Transformer; @@ -78,7 +79,7 @@ public function __construct( */ public function defaultContext(object $data): TransformationContext { - if (! $data instanceof IncludeableData || $data->getPartialsDefinition()->isEmpty()) { + if (! $data instanceof IncludeableData || ! $data->hasPartialsDefinition()) { return $this->defaultContext; } @@ -92,7 +93,7 @@ public function defaultContext(object $data): TransformationContext */ public function allContext(object $data): TransformationContext { - if (! $data instanceof IncludeableData || $data->getPartialsDefinition()->isEmpty()) { + if (! $data instanceof IncludeableData || ! $data->hasPartialsDefinition()) { return $this->allContext; } @@ -191,7 +192,7 @@ protected function transformData( } // Raw storage keeps excluded property hooks from running as a side effect. - $values = get_mangled_object_vars($data); + $values = (array) $data; $transformed = []; foreach ($dataClass->properties as $property) { @@ -268,6 +269,7 @@ protected function transformCollectable( foreach ($rootItems ?? $this->collectableItems($data) as $key => $item) { if (! $context->transformValues) { if ($context->hasPartials() && $item instanceof IncludeableData) { + // Non-transforming root contexts compile their selections from these same definitions. $item->getPartialsDefinition()->addResolved($context->partialDefinitions); } @@ -430,7 +432,7 @@ protected function transformUsingRecipe( array &$extensions, ): array { // Raw storage keeps uninitialized properties from invoking public access. - $values = get_mangled_object_vars($data); + $values = (array) $data; $transformed = []; foreach ($recipe->properties as $property) { @@ -605,17 +607,14 @@ protected function mergeInstancePartials( BaseData|BaseDataCollectable $value, TransformationContext $context, ): TransformationContext { - if ($context->constructable || ! $value instanceof IncludeableData) { - return $context; - } - - $partialDefinitions = $value->getPartialsDefinition(); - - if ($partialDefinitions->isEmpty()) { + if ($context->constructable + || ! $value instanceof IncludeableData + || ! $value->hasPartialsDefinition() + ) { return $context; } - return $context->withMergedPartials($partialDefinitions->resolve( + return $context->withMergedPartials($value->getPartialsDefinition()->resolve( $value, consumeTemporary: true, )); @@ -834,9 +833,13 @@ protected function propagatePartials( return; } - $value->getPartialsDefinition()->addResolved( - $context->partialsForNestedProperty($property), - ); + $definitions = $context->partialsForNestedProperty($property); + + if (! self::hasResolvedPartials($definitions)) { + return; + } + + $value->getPartialsDefinition()->addResolved($definitions); } /** @@ -853,6 +856,10 @@ protected function propagateIterablePartials( $definitions = $context->partialsForNestedProperty($property); + if (! self::hasResolvedPartials($definitions)) { + return; + } + foreach ($items as $item) { if ($item instanceof IncludeableData) { $item->getPartialsDefinition()->addResolved($definitions); @@ -860,6 +867,19 @@ protected function propagateIterablePartials( } } + /** + * Determine whether a resolved partial set contains any definitions. + * + * @param array{include: list, exclude: list, only: list, except: list} $definitions + */ + private static function hasResolvedPartials(array $definitions): bool + { + return $definitions['include'] !== [] + || $definitions['exclude'] !== [] + || $definitions['only'] !== [] + || $definitions['except'] !== []; + } + /** * Apply only and except selections to a plain array value. * diff --git a/src/data/src/Support/Transformation/TransformationContextFactory.php b/src/data/src/Support/Transformation/TransformationContextFactory.php index d13fd8b40..030720ba5 100644 --- a/src/data/src/Support/Transformation/TransformationContextFactory.php +++ b/src/data/src/Support/Transformation/TransformationContextFactory.php @@ -89,7 +89,7 @@ public function get(object $data): TransformationContext return static::persistenceContext($this->configuredMaxDepth); } - $dataPartials = $data instanceof IncludeableData + $dataPartials = $data instanceof IncludeableData && $data->hasPartialsDefinition() ? $data->getPartialsDefinition() : null; diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index 1cadf56ff..22982668b 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -569,7 +569,7 @@ Use `withValidator(Validator $validator)` and `after(): array` like a FormReques ### Creation Factories -The `factory` method returns a fluent factory for a single creation: +The `factory` method returns a fluent factory for creating data objects: ```php $user = UserData::factory() @@ -582,6 +582,19 @@ $user = UserData::factory() ->from($payload); ``` +Within one operation, you may reuse a factory to avoid repeating creation setup for every payload: + +```php +$factory = UserData::factory(); + +$users = array_map( + fn (array $payload): UserData => $factory->from($payload), + $payloads, +); +``` + +Use `collect()` instead when the payloads form one collection. In addition to preserving supported collection shapes and keys, `collect()` allows collection validation rules and hooks to inspect the complete payload. + Factories may change the validation strategy, enable or disable name mapping and named factories, ignore selected named methods, and add casts or normalizers. They also provide the following hooks, which run in this order: 1. `prepareData` @@ -595,7 +608,7 @@ Factories may change the validation strategy, enable or disable name mapping and The `prepareData`, `beforeCreation`, and `afterCreation` hooks run even when validation is skipped. The other hooks run while generating rules or validating, as appropriate. Call `alwaysValidate()` when validation hooks should also apply to an array, model, JSON value, or another non-request source. -Each call to `factory()` returns a new factory. Configure and use the factory where it is created instead of storing one and reusing it across requests. +Each call to `factory()` returns a new factory. Keep a reused factory scoped to the current operation instead of storing it across requests. ## Transformation diff --git a/tests/Benchmarks/Data/compare-data-object.php b/tests/Benchmarks/Data/compare-data-object.php index f5edbdcad..153f1b67e 100644 --- a/tests/Benchmarks/Data/compare-data-object.php +++ b/tests/Benchmarks/Data/compare-data-object.php @@ -422,16 +422,21 @@ function measureCold(string $mode): array /** * Measure retained instance bytes over a large held set. * - * @param Closure(int): object $factory + * @template TInstance of object + * + * @param Closure(int): TInstance $factory + * @param null|Closure(TInstance): void $prepare */ -function retainedInstanceBytes(Closure $factory): float +function retainedInstanceBytes(Closure $factory, ?Closure $prepare = null): float { gc_collect_cycles(); $baseline = memory_get_usage(false); $instances = []; for ($index = 1; $index <= 20_000; ++$index) { - $instances[] = $factory($index); + $instance = $factory($index); + $prepare?->__invoke($instance); + $instances[] = $instance; } $bytes = (memory_get_usage(false) - $baseline) / count($instances); @@ -666,8 +671,31 @@ function () use ($newObjects): int { } printf("\nRetained instance bytes\n"); - printf("%-38s %12.1f %12.1f\n", 'flat', retainedInstanceBytes(fn (int $id): OldFlat => OldFlat::from([...$flat, 'id' => $id])), retainedInstanceBytes(fn (int $id): NewFlat => NewFlat::from([...$flat, 'id' => $id]))); - printf("%-38s %12.1f %12.1f\n", 'wide', retainedInstanceBytes(fn (int $id): OldWide => OldWide::from([...$wide, 'one' => $id])), retainedInstanceBytes(fn (int $id): NewWide => NewWide::from([...$wide, 'one' => $id]))); + printf("%-38s %12s %12s\n", 'scenario', 'old', 'data'); + printf("%-38s %12.1f %12.1f\n", 'flat, untransformed', retainedInstanceBytes(fn (int $id): OldFlat => OldFlat::from([...$flat, 'id' => $id])), retainedInstanceBytes(fn (int $id): NewFlat => NewFlat::from([...$flat, 'id' => $id]))); + printf("%-38s %12.1f %12.1f\n", 'flat, transformed', retainedInstanceBytes( + fn (int $id): OldFlat => OldFlat::from([...$flat, 'id' => $id]), + static function (OldFlat $data): void { + $data->toArray(); + }, + ), retainedInstanceBytes( + fn (int $id): NewFlat => NewFlat::from([...$flat, 'id' => $id]), + static function (NewFlat $data): void { + $data->toArray(); + }, + )); + printf("%-38s %12.1f %12.1f\n", 'wide, untransformed', retainedInstanceBytes(fn (int $id): OldWide => OldWide::from([...$wide, 'one' => $id])), retainedInstanceBytes(fn (int $id): NewWide => NewWide::from([...$wide, 'one' => $id]))); + printf("%-38s %12.1f %12.1f\n", 'wide, transformed', retainedInstanceBytes( + fn (int $id): OldWide => OldWide::from([...$wide, 'one' => $id]), + static function (OldWide $data): void { + $data->toArray(); + }, + ), retainedInstanceBytes( + fn (int $id): NewWide => NewWide::from([...$wide, 'one' => $id]), + static function (NewWide $data): void { + $data->toArray(); + }, + )); $repository = $application->make(DataClassRepository::class); $repository->get(NewWarm::class); diff --git a/tests/Data/Support/DataClassTest.php b/tests/Data/Support/DataClassTest.php index 242c850c2..b3db4f062 100644 --- a/tests/Data/Support/DataClassTest.php +++ b/tests/Data/Support/DataClassTest.php @@ -24,6 +24,7 @@ use Hypervel\Data\Contracts\PropertyMorphableData; use Hypervel\Data\Data; use Hypervel\Data\DataCollection; +use Hypervel\Data\Dto; use Hypervel\Data\Enums\DataPropertyOperation; use Hypervel\Data\Exceptions\InvalidDataDeclaration; use Hypervel\Data\Lazy; @@ -191,6 +192,19 @@ public function testBulkCopyMetadataDistinguishesArrayAndExtensionShapes(): void $this->assertNull($annotated->transformationRecipe); } + /** + * Test non-transformable data does not retain unreachable transformation metadata. + */ + public function testNonTransformableDataSkipsTransformationMetadata(): void + { + $class = $this->factory()->build(new ReflectionClass(NonTransformableDtoFixture::class)); + + $this->assertFalse($class->transformable); + $this->assertFalse($class->bulkCopyTransformation); + $this->assertNull($class->transformationRecipe); + $this->assertNotNull($class->creationRecipe); + } + /** * Test iterable annotation precedence and declaration scopes. */ @@ -470,7 +484,7 @@ protected function iterableItemName(DataProperty $property): string #[ErrorBag('metadata')] #[RedirectTo('/metadata')] #[RedirectToRoute('metadata.store')] -class DataClassMetadataFixture +class DataClassMetadataFixture extends Data { /** * Create a new metadata fixture. @@ -506,7 +520,7 @@ public static function rules(): array } } -class ConstructorBindingDataFixture +class ConstructorBindingDataFixture extends Data { public readonly string $readonlyName; @@ -597,6 +611,13 @@ class PlainArrayTransformationFixture extends Data public array $values = []; } +class NonTransformableDtoFixture extends Dto +{ + public function __construct(public int $id) + { + } +} + class PropertyTransformerDataFixture extends Data { #[WithTransformer(DataClassTransformerFixture::class)] diff --git a/tests/Data/Support/Transformation/DataTransformerTest.php b/tests/Data/Support/Transformation/DataTransformerTest.php index d86fdf1e6..07c0ec51a 100644 --- a/tests/Data/Support/Transformation/DataTransformerTest.php +++ b/tests/Data/Support/Transformation/DataTransformerTest.php @@ -30,6 +30,7 @@ use Hypervel\Data\Normalizers\Normalizer; use Hypervel\Data\Support\DataClass; use Hypervel\Data\Support\DataProperty; +use Hypervel\Data\Support\Partials\PartialsDefinition; use Hypervel\Data\Support\Transformation\DataTransformer; use Hypervel\Data\Support\Transformation\TransformationContext; use Hypervel\Data\Support\Transformation\TransformationContextFactory; @@ -40,6 +41,7 @@ use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\LengthAwarePaginator; use Hypervel\Testbench\TestCase; +use ReflectionProperty; use RuntimeException; use Traversable; @@ -385,6 +387,121 @@ public function testIncludesAndExcludesLazyValues(): void $this->assertArrayNotHasKey('excluded', $transformed); } + /** + * Test plain transformations do not retain an empty partial definition store. + */ + public function testPlainTransformationsDoNotRetainEmptyPartials(): void + { + $data = new SimpleData('value'); + + $this->assertNull($this->partialDefinitionsState($data)); + $this->assertSame(['value' => 'value'], $data->toArray()); + $this->assertFalse($this->partialDefinitionsState($data)); + $this->assertSame(['value' => 'value'], $data->all()); + $this->assertFalse($this->partialDefinitionsState($data)); + + TransformationContextFactory::create()->get($data); + + $this->assertFalse($this->partialDefinitionsState($data)); + + $data->only('value'); + + $this->assertInstanceOf(PartialsDefinition::class, $this->partialDefinitionsState($data)); + $this->assertSame(['value' => 'value'], $data->toArray()); + $this->assertFalse($data->hasPartialsDefinition()); + } + + /** + * Test class-owned partial defaults still initialize once and remain active. + */ + public function testClassOwnedPartialDefaultsStillInitialize(): void + { + $data = new DefaultPartialsData('first', 'second'); + + $this->assertNull($this->partialDefinitionsState($data)); + $this->assertTrue($data->hasPartialsDefinition()); + $this->assertInstanceOf(PartialsDefinition::class, $this->partialDefinitionsState($data)); + $this->assertSame(['first' => 'first'], $data->toArray()); + $this->assertTrue($data->hasPartialsDefinition()); + } + + /** + * Test disabled class-owned defaults retain the empty sentinel. + */ + public function testDisabledClassOwnedPartialDefaultsRemainEmpty(): void + { + $data = new DisabledDefaultPartialsData('first', 'second'); + + $this->assertFalse($data->hasPartialsDefinition()); + $this->assertFalse($this->partialDefinitionsState($data)); + $this->assertSame([ + 'first' => 'first', + 'second' => 'second', + ], $data->toArray()); + $this->assertFalse($this->partialDefinitionsState($data)); + } + + /** + * Test plain collections and their items do not retain empty partial stores. + */ + public function testPlainCollectionsDoNotRetainEmptyPartials(): void + { + $first = new SimpleData('first'); + $second = new SimpleData('second'); + $collection = new DataCollection(SimpleData::class, [$first, $second]); + + $this->assertSame([ + ['value' => 'first'], + ['value' => 'second'], + ], $collection->toArray()); + $this->assertFalse($this->partialDefinitionsState($collection)); + $this->assertFalse($this->partialDefinitionsState($first)); + $this->assertFalse($this->partialDefinitionsState($second)); + } + + /** + * Test unrelated parent partials do not allocate stores on nested values or iterable items. + */ + public function testUnrelatedParentPartialsDoNotAllocateNestedStores(): void + { + $nested = new NestedLazyData( + Lazy::create(static fn (): string => 'temporary'), + Lazy::create(static fn (): string => 'permanent'), + ); + $first = new NestedLazyData( + Lazy::create(static fn (): string => 'first'), + Lazy::create(static fn (): string => 'ignored'), + ); + $second = new NestedLazyData( + Lazy::create(static fn (): string => 'second'), + Lazy::create(static fn (): string => 'ignored'), + ); + + (new PartialOwnerData($nested))->include('enabled')->all(); + (new DataArrayOwner([$first, $second]))->include('items')->all(); + + $this->assertNull($this->partialDefinitionsState($nested)); + $this->assertNull($this->partialDefinitionsState($first)); + $this->assertNull($this->partialDefinitionsState($second)); + } + + /** + * Test root collection partials still propagate to unchanged items. + */ + public function testRootCollectionPartialsPropagateToUnchangedItems(): void + { + $item = new NestedLazyData( + Lazy::create(static fn (): string => 'temporary'), + Lazy::create(static fn (): string => 'ignored'), + ); + $collection = (new DataCollection(NestedLazyData::class, [$item])) + ->include('temporary'); + + $this->assertSame([$item], $collection->all()); + $this->assertInstanceOf(PartialsDefinition::class, $this->partialDefinitionsState($item)); + $this->assertSame(['temporary' => 'temporary'], $item->toArray()); + } + /** * Test automatic lazy values retain their owning transformation semantics. */ @@ -937,6 +1054,17 @@ public function testPersistenceResolvesIncludedConditionalAndLoadedRelationalVal ], $data->transform(TransformationContextFactory::forPersistence())); $this->assertSame(1, $model->relationReads); } + + /** + * Get the internal partial definition state without initializing it. + */ + private function partialDefinitionsState(object $data): PartialsDefinition|false|null + { + /** @var null|false|PartialsDefinition $state */ + $state = (new ReflectionProperty($data, 'partialDefinitions'))->getValue($data); + + return $state; + } } class BulkCopyRecordingDataTransformer extends DataTransformer @@ -970,6 +1098,40 @@ public function __construct(public string $value) } } +class DefaultPartialsData extends Data +{ + public function __construct( + public string $first, + public string $second, + ) { + } + + /** + * Keep the first property by default. + */ + protected function onlyProperties(): array + { + return ['first']; + } +} + +class DisabledDefaultPartialsData extends Data +{ + public function __construct( + public string $first, + public string $second, + ) { + } + + /** + * Disable the conditional default selection. + */ + protected function onlyProperties(): array + { + return ['first' => false]; + } +} + class PropertyTransformedData extends Data { public function __construct(