From 76114be91c3b7034196bef0ca876fdbca6caf8ce Mon Sep 17 00:00:00 2001 From: ragulka Date: Wed, 15 Jan 2025 12:28:30 +0200 Subject: [PATCH 1/4] Allow disabling Optional values in CreationContext --- docs/as-a-data-transfer-object/factories.md | 21 +++++++++++++++++++ src/DataPipes/DefaultValuesDataPipe.php | 2 +- src/Support/Creation/CreationContext.php | 1 + .../Creation/CreationContextFactory.php | 18 ++++++++++++++++ .../Creation/CreationContextFactoryTest.php | 16 ++++++++++++++ 5 files changed, 57 insertions(+), 1 deletion(-) diff --git a/docs/as-a-data-transfer-object/factories.md b/docs/as-a-data-transfer-object/factories.md index 9d10772ac..1c458b870 100644 --- a/docs/as-a-data-transfer-object/factories.md +++ b/docs/as-a-data-transfer-object/factories.md @@ -60,6 +60,27 @@ It is also possible to ignore the magical creation methods when creating a data SongData::factory()->ignoreMagicalMethod('fromString')->from('Never gonna give you up'); // Won't work since the magical method is ignored ``` +## Disabling optional values + +When creating a data object that has optional properties, it is possible choose whether missing properties from the payload should be created as `Optional`. This can be helpful when you want to have a `null` value instead of an `Optional` object - for example, when creating the DTO from an Eloquent model with `null` values. + +```php +use \Spatie\LaravelData\Optional; + +class SongData extends Data { + public function __construct( + public string $title, + public string $artist, + public Optional|null|string $album, + ) { + } +} + +SongData::factory() + ->withoutOptionalValues() + ->from(['title' => 'Never gonna give you up', 'artist' => 'Rick Astley']); // album will `null` instead of `Optional` +``` + ## Adding additional global casts When creating a data object, it is possible to add additional casts to the data object: diff --git a/src/DataPipes/DefaultValuesDataPipe.php b/src/DataPipes/DefaultValuesDataPipe.php index 38ab18086..a89ea37d3 100644 --- a/src/DataPipes/DefaultValuesDataPipe.php +++ b/src/DataPipes/DefaultValuesDataPipe.php @@ -25,7 +25,7 @@ public function handle( continue; } - if ($property->type->isOptional) { + if ($property->type->isOptional && $creationContext->useOptionalValues) { $properties[$name] = Optional::create(); continue; diff --git a/src/Support/Creation/CreationContext.php b/src/Support/Creation/CreationContext.php index f7de00662..55d421af9 100644 --- a/src/Support/Creation/CreationContext.php +++ b/src/Support/Creation/CreationContext.php @@ -34,6 +34,7 @@ public function __construct( public ValidationStrategy $validationStrategy, public readonly bool $mapPropertyNames, public readonly bool $disableMagicalCreation, + public readonly bool $useOptionalValues, public readonly ?array $ignoredMagicalMethods, public readonly ?GlobalCastsCollection $casts, ) { diff --git a/src/Support/Creation/CreationContextFactory.php b/src/Support/Creation/CreationContextFactory.php index fed7ff324..8d4e8e13b 100644 --- a/src/Support/Creation/CreationContextFactory.php +++ b/src/Support/Creation/CreationContextFactory.php @@ -31,6 +31,7 @@ public function __construct( public ValidationStrategy $validationStrategy, public bool $mapPropertyNames, public bool $disableMagicalCreation, + public bool $useOptionalValues, public ?array $ignoredMagicalMethods, public ?GlobalCastsCollection $casts, ) { @@ -47,6 +48,7 @@ public static function createFromConfig( validationStrategy: ValidationStrategy::from($config['validation_strategy']), mapPropertyNames: true, disableMagicalCreation: false, + useOptionalValues: true, ignoredMagicalMethods: null, casts: null, ); @@ -61,6 +63,7 @@ public static function createFromCreationContext( validationStrategy: $creationContext->validationStrategy, mapPropertyNames: $creationContext->mapPropertyNames, disableMagicalCreation: $creationContext->disableMagicalCreation, + useOptionalValues: $creationContext->useOptionalValues, ignoredMagicalMethods: $creationContext->ignoredMagicalMethods, casts: $creationContext->casts, ); @@ -122,6 +125,20 @@ public function withMagicalCreation(bool $withMagicalCreation = true): self return $this; } + public function withOptionalValues(bool $withOptionalValues = true): self + { + $this->useOptionalValues = $withOptionalValues; + + return $this; + } + + public function withoutOptionalValues(bool $withoutOptionalValues = true): self + { + $this->useOptionalValues = ! $withoutOptionalValues; + + return $this; + } + public function ignoreMagicalMethod(string ...$methods): self { $this->ignoredMagicalMethods ??= []; @@ -173,6 +190,7 @@ public function get(): CreationContext validationStrategy: $this->validationStrategy, mapPropertyNames: $this->mapPropertyNames, disableMagicalCreation: $this->disableMagicalCreation, + useOptionalValues: $this->useOptionalValues, ignoredMagicalMethods: $this->ignoredMagicalMethods, casts: $this->casts, ); diff --git a/tests/Support/Creation/CreationContextFactoryTest.php b/tests/Support/Creation/CreationContextFactoryTest.php index 8183b5dc5..b32d1c5e9 100644 --- a/tests/Support/Creation/CreationContextFactoryTest.php +++ b/tests/Support/Creation/CreationContextFactoryTest.php @@ -85,6 +85,22 @@ expect($context->disableMagicalCreation)->toBeFalse(); }); +it('is possible to disable optional values', function () { + $context = CreationContextFactory::createFromConfig( + SimpleData::class + )->withoutOptionalValues(); + + expect($context->useOptionalValues)->toBeFalse(); +}); + +it('is possible to enable optional values', function () { + $context = CreationContextFactory::createFromConfig( + SimpleData::class + )->withOptionalValues(); + + expect($context->useOptionalValues)->toBeTrue(); +}); + it('is possible to set ignored magical methods', function () { $context = CreationContextFactory::createFromConfig( SimpleData::class From b22cc7b3fc0216c5f1322cfa58e65ff4c5218c02 Mon Sep 17 00:00:00 2001 From: ragulka Date: Sat, 18 Jan 2025 09:02:13 +0200 Subject: [PATCH 2/4] Add withoutOptionalValues tests --- tests/CreationTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/CreationTest.php b/tests/CreationTest.php index b2858ba09..3f9c61b15 100644 --- a/tests/CreationTest.php +++ b/tests/CreationTest.php @@ -1240,3 +1240,22 @@ public static function pipeline(): DataPipeline [10, SimpleData::from('Hello World')] ); })->todo(); + +it('can be created without optional values', function () { + $dataClass = new class () extends Data { + public string $name; + + public string|null|Optional $description; + public string|Optional $slug; + }; + + $data = $dataClass::factory() + ->withoutOptionalValues() + ->from([ + 'name' => 'Ruben', + ]); + + expect($data->name)->toBe('Ruben'); + expect($data->description)->toBeNull(); + expect(isset($data->slug))->toBeFalse(); +}); From 8312785f877b5d1c64069be66595263ea13bb205 Mon Sep 17 00:00:00 2001 From: ragulka Date: Sat, 18 Jan 2025 09:09:53 +0200 Subject: [PATCH 3/4] Add default value fallback to test --- tests/CreationTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/CreationTest.php b/tests/CreationTest.php index 3f9c61b15..7601973be 100644 --- a/tests/CreationTest.php +++ b/tests/CreationTest.php @@ -1246,7 +1246,11 @@ public static function pipeline(): DataPipeline public string $name; public string|null|Optional $description; + + public int|Optional $year = 2025; + public string|Optional $slug; + }; $data = $dataClass::factory() @@ -1257,5 +1261,6 @@ public static function pipeline(): DataPipeline expect($data->name)->toBe('Ruben'); expect($data->description)->toBeNull(); + expect($data->year)->toBe(2025); expect(isset($data->slug))->toBeFalse(); }); From 6d359bce759688e53a5c7b3f2af2039eff044700 Mon Sep 17 00:00:00 2001 From: ragulka Date: Sat, 18 Jan 2025 09:16:30 +0200 Subject: [PATCH 4/4] Update factories.md --- docs/as-a-data-transfer-object/factories.md | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/as-a-data-transfer-object/factories.md b/docs/as-a-data-transfer-object/factories.md index 1c458b870..229e36dee 100644 --- a/docs/as-a-data-transfer-object/factories.md +++ b/docs/as-a-data-transfer-object/factories.md @@ -81,6 +81,29 @@ SongData::factory() ->from(['title' => 'Never gonna give you up', 'artist' => 'Rick Astley']); // album will `null` instead of `Optional` ``` +Note that when an Optional property has no default value, and is not nullable, and the payload does not contain a value for this property, the DTO will not have the property set - so accessing it can throw `Typed property must not be accessed before initialization` error. Therefore, it's advisable to either set a default value or make the property nullable, when using `withoutOptionalValues`. + +```php +class SongData extends Data { + public function __construct( + public string $title, + public string $artist, + public Optional|string $album, // careful here! + public Optional|string $publisher = 'unknown', + public Optional|string|null $label, + ) { + } +} + +$data = SongData::factory() + ->withoutOptionalValues() + ->from(['title' => 'Never gonna give you up', 'artist' => 'Rick Astley']); + +$data->toArray(); // ['title' => 'Never gonna give you up', 'artist' => 'Rick Astley', 'publisher' => 'unknown', 'label' => null] + +$data->album; // accessing the album will throw an error, unless the property is set before accessing it +``` + ## Adding additional global casts When creating a data object, it is possible to add additional casts to the data object: