From 86396b7d54446003e3de0e86efba1ec5c737d2a5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:27:33 +0000 Subject: [PATCH 1/5] Optimize data partial state allocation Represent unevaluated, empty, and populated partial definitions explicitly so ordinary data objects do not retain an empty mutable store after transformation. Skip empty nested propagation and preserve class-owned defaults and temporary partial consumption semantics. Use non-retaining raw object casts on the general and fixed-recipe paths while keeping hook-aware bulk reads unchanged. Add focused coverage for root objects, collections, nested values, defaults, and explicit sentinel upgrades. --- src/data/src/Concerns/IncludeableData.php | 55 +++++++- src/data/src/Contracts/IncludeableData.php | 5 + .../Transformation/DataTransformer.php | 50 +++++-- .../TransformationContextFactory.php | 2 +- .../Transformation/DataTransformerTest.php | 129 ++++++++++++++++++ 5 files changed, 219 insertions(+), 22 deletions(-) diff --git a/src/data/src/Concerns/IncludeableData.php b/src/data/src/Concerns/IncludeableData.php index 16ba0c36b..7efb3f79f 100644 --- a/src/data/src/Concerns/IncludeableData.php +++ b/src/data/src/Concerns/IncludeableData.php @@ -11,22 +11,65 @@ 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; + } + + $this->partialDefinitions = new PartialsDefinition; + $this->partialDefinitions->addDefaults('include', $includes); + $this->partialDefinitions->addDefaults('exclude', $excludes); + $this->partialDefinitions->addDefaults('only', $only); + $this->partialDefinitions->addDefaults('except', $except); + + 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/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/tests/Data/Support/Transformation/DataTransformerTest.php b/tests/Data/Support/Transformation/DataTransformerTest.php index d86fdf1e6..383b38548 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,105 @@ 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 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 +1038,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 +1082,23 @@ 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 PropertyTransformedData extends Data { public function __construct( From dd871c9431b6e7f5ea134cced74327f8b2f3f369 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:27:48 +0000 Subject: [PATCH 2/5] Skip transformation metadata for DTOs Compile transformation recipes only for classes that implement TransformableData. DTOs retain their creation metadata without carrying an unreachable ordered transformation recipe or bulk-copy flag. Update the two existing metadata fixtures to extend Data because those tests intentionally assert transformation behavior and the production repository accepts BaseData classes. Keep lower-level plain fixtures unchanged so declaration analysis remains independently covered. --- .../Support/Factories/DataClassFactory.php | 7 ++++-- tests/Data/Support/DataClassTest.php | 25 +++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) 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/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)] From fa186e58e16c8ae420efd152eb73e652c80f3fa8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:28:02 +0000 Subject: [PATCH 3/5] Measure transformed data instance retention Extend the Data comparison harness with a typed preparation callback and report retained memory before and after transformation for flat and wide objects. This separates package-owned object state from PHP's property-table materialization and keeps future memory comparisons honest across both Data and the removed lightweight DataObject fixture. --- tests/Benchmarks/Data/compare-data-object.php | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) 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); From 201bdaa3657d528620c2e0ba2bfabc872bfd25ce Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:28:17 +0000 Subject: [PATCH 4/5] Document operation-scoped data factories Show how to reuse a creation factory within one operation to avoid repeating per-call setup, while keeping mutable factories out of request-spanning state. Recommend collect() when payloads form one collection so supported shapes, keys, validation rules, and collection hooks retain their intended semantics. --- src/docs/data-objects.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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 From 526e93bbb446a4cedda12d07dd777cc6325dae81 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:53:12 +0000 Subject: [PATCH 5/5] Handle disabled default partial definitions Build class-owned partial defaults into a local store and retain it only when at least one definition survives normalization. Defaults expressed as an associative false value now resolve to the empty sentinel instead of reporting a populated store. Add focused coverage proving the predicate, internal sentinel, and transformed output remain consistent when a conditional default is disabled. --- src/data/src/Concerns/IncludeableData.php | 18 +++++++--- .../Transformation/DataTransformerTest.php | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/data/src/Concerns/IncludeableData.php b/src/data/src/Concerns/IncludeableData.php index 7efb3f79f..09607f4ea 100644 --- a/src/data/src/Concerns/IncludeableData.php +++ b/src/data/src/Concerns/IncludeableData.php @@ -42,11 +42,19 @@ public function hasPartialsDefinition(): bool return false; } - $this->partialDefinitions = new PartialsDefinition; - $this->partialDefinitions->addDefaults('include', $includes); - $this->partialDefinitions->addDefaults('exclude', $excludes); - $this->partialDefinitions->addDefaults('only', $only); - $this->partialDefinitions->addDefaults('except', $except); + $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; } diff --git a/tests/Data/Support/Transformation/DataTransformerTest.php b/tests/Data/Support/Transformation/DataTransformerTest.php index 383b38548..07c0ec51a 100644 --- a/tests/Data/Support/Transformation/DataTransformerTest.php +++ b/tests/Data/Support/Transformation/DataTransformerTest.php @@ -425,6 +425,22 @@ public function testClassOwnedPartialDefaultsStillInitialize(): void $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. */ @@ -1099,6 +1115,23 @@ protected function onlyProperties(): array } } +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(