diff --git a/docs/advanced-usage/typescript.md b/docs/advanced-usage/typescript.md index 08de76034..a4b496de0 100644 --- a/docs/advanced-usage/typescript.md +++ b/docs/advanced-usage/typescript.md @@ -128,3 +128,48 @@ class DataObject extends Data } } ``` + +### Dotted Notation Expansion + +By default, dotted notation in property names is kept as-is in TypeScript. You can expand it into nested interfaces using the same **four approaches** as in data transformation: + +```php +class UserData extends Data +{ + public function __construct( + // 1. MapDotExpandedOutputName - output only + #[MapDotExpandedOutputName('user.profile.name')] + public string $name, + + // 2. MapDotExpandedName - input & output + #[MapDotExpandedName('user.profile.email')] + public string $email, + + // 3. MapOutputName with parameter - output only + #[MapOutputName('user.settings.theme', expandDotNotation: true)] + public string $theme, + + // 4. MapName with parameter - input & output + #[MapName('user.settings.language', expandDotNotation: true)] + public string $language, + ) { + } +} +``` + +This generates nested TypeScript interfaces: + +```tsx +{ + user: { + profile: { + name: string; + email: string; + }; + settings: { + theme: string; + language: string; + }; + }; +} +``` diff --git a/docs/as-a-data-transfer-object/mapping-property-names.md b/docs/as-a-data-transfer-object/mapping-property-names.md index 2251ec223..ca9c7bb31 100644 --- a/docs/as-a-data-transfer-object/mapping-property-names.md +++ b/docs/as-a-data-transfer-object/mapping-property-names.md @@ -92,3 +92,5 @@ SongData::from([ ``` The package has a set of default mappers available, you can find them [here](/docs/laravel-data/v4/advanced-usage/available-property-mappers). + +When transforming data objects with properties that use dotted notation, you can enable the expansion of these properties into nested arrays. See [Expanding Dotted Notation](/docs/laravel-data/v4/as-a-resource/mapping-property-names#expanding-dotted-notation) for more details. diff --git a/docs/as-a-resource/mapping-property-names.md b/docs/as-a-resource/mapping-property-names.md index c21faff84..b214315e5 100644 --- a/docs/as-a-resource/mapping-property-names.md +++ b/docs/as-a-resource/mapping-property-names.md @@ -82,3 +82,53 @@ And a transformed version of the data object will look like this: ``` The package has a set of default mappers available, you can find them [here](/docs/laravel-data/v4/advanced-usage/available-property-mappers). + +## Expanding Dotted Notation + +By default, dotted notation in property names (e.g., 'user.name') is kept as-is in the output. You can enable expansion to create nested arrays using **four different approaches**: + +```php +class UserData extends Data +{ + public function __construct( + // 1. MapDotExpandedOutputName - output only, cleaner syntax + #[MapDotExpandedOutputName('user.profile.name')] + public string $name, + + // 2. MapDotExpandedName - input & output, cleaner syntax + #[MapDotExpandedName('user.profile.email')] + public string $email, + + // 3. MapOutputName with parameter - output only, explicit + #[MapOutputName('user.settings.theme', expandDotNotation: true)] + public string $theme, + + // 4. MapName with parameter - input & output, explicit + #[MapName('user.settings.language', expandDotNotation: true)] + public string $language, + ) { + } +} +``` + +All four approaches transform to nested arrays: + +```php +[ + 'user' => [ + 'profile' => [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ], + 'settings' => [ + 'theme' => 'dark', + 'language' => 'en', + ], + ], +] +``` + +**Choosing the right attribute:** +- Use `MapDotExpandedOutputName` or `MapOutputName(..., expandDotNotation: true)` for output-only transformation +- Use `MapDotExpandedName` or `MapName(..., expandDotNotation: true)` for both input mapping and output transformation +- Dedicated attributes provide cleaner syntax; parameter-based approach offers more flexibility diff --git a/src/Attributes/MapDotExpandedName.php b/src/Attributes/MapDotExpandedName.php new file mode 100644 index 000000000..7a1473f6e --- /dev/null +++ b/src/Attributes/MapDotExpandedName.php @@ -0,0 +1,14 @@ +output ??= $this->input; } } diff --git a/src/Attributes/MapOutputName.php b/src/Attributes/MapOutputName.php index 9d6c5a8e7..9c28452f9 100644 --- a/src/Attributes/MapOutputName.php +++ b/src/Attributes/MapOutputName.php @@ -7,7 +7,9 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)] class MapOutputName { - public function __construct(public string|int $output) - { + public function __construct( + public string|int $output, + public bool $expandDotNotation = false, + ) { } } diff --git a/src/Resolvers/EmptyDataResolver.php b/src/Resolvers/EmptyDataResolver.php index b55778dd4..8c3f757a7 100644 --- a/src/Resolvers/EmptyDataResolver.php +++ b/src/Resolvers/EmptyDataResolver.php @@ -2,6 +2,7 @@ namespace Spatie\LaravelData\Resolvers; +use Illuminate\Support\Arr; use Spatie\LaravelData\Concerns\EmptyData; use Spatie\LaravelData\Exceptions\DataPropertyCanOnlyHaveOneType; use Spatie\LaravelData\Support\DataConfig; @@ -24,10 +25,14 @@ public function execute(string $class, array $extra = [], mixed $defaultReturnVa foreach ($dataClass->properties as $property) { $name = $property->outputMappedName ?? $property->name; - if ($property->hasDefaultValue) { - $payload[$name] = $property->defaultValue; + $value = $property->hasDefaultValue + ? $property->defaultValue + : ($extra[$property->name] ?? $this->getValueForProperty($property, $defaultReturnValue)); + + if ($property->expandDotNotation) { + Arr::set($payload, $name, $value); } else { - $payload[$name] = $extra[$property->name] ?? $this->getValueForProperty($property, $defaultReturnValue); + $payload[$name] = $value; } } diff --git a/src/Resolvers/NameMappersResolver.php b/src/Resolvers/NameMappersResolver.php index c223af548..2fe12e2f0 100644 --- a/src/Resolvers/NameMappersResolver.php +++ b/src/Resolvers/NameMappersResolver.php @@ -26,6 +26,7 @@ public function execute( return [ 'inputNameMapper' => $this->resolveInputNameMapper($attributes), 'outputNameMapper' => $this->resolveOutputNameMapper($attributes), + 'expandDotNotation' => $this->resolveExpandDotNotation($attributes), ]; } @@ -94,4 +95,13 @@ protected function resolveDefaultNameMapper( return $this->resolveMapperClass($value); } + + protected function resolveExpandDotNotation( + DataAttributesCollection $attributes + ): bool { + $mapper = $attributes->first(MapOutputName::class) + ?? $attributes->first(MapName::class); + + return $mapper->expandDotNotation ?? false; + } } diff --git a/src/Resolvers/TransformedDataResolver.php b/src/Resolvers/TransformedDataResolver.php index 94008b173..0a8706de3 100644 --- a/src/Resolvers/TransformedDataResolver.php +++ b/src/Resolvers/TransformedDataResolver.php @@ -83,7 +83,12 @@ private function transform( $name = $property->outputMappedName; } - $payload[$name] = $value; + if ($property->expandDotNotation) { + Arr::set($payload, $name, $value); + } + else { + $payload[$name] = $value; + } } return $payload; diff --git a/src/Support/DataProperty.php b/src/Support/DataProperty.php index 57aded8bf..21ae74b7f 100644 --- a/src/Support/DataProperty.php +++ b/src/Support/DataProperty.php @@ -25,6 +25,7 @@ public function __construct( public readonly ?Transformer $transformer, public readonly ?string $inputMappedName, public readonly ?string $outputMappedName, + public readonly bool $expandDotNotation, public readonly DataAttributesCollection $attributes, ) { } diff --git a/src/Support/Factories/DataPropertyFactory.php b/src/Support/Factories/DataPropertyFactory.php index 7b5744d49..70c8af308 100644 --- a/src/Support/Factories/DataPropertyFactory.php +++ b/src/Support/Factories/DataPropertyFactory.php @@ -59,6 +59,8 @@ public function build( default => null, }; + $expandDotNotation = $mappers['expandDotNotation']; + if (! $reflectionProperty->isPromoted()) { $hasDefaultValue = $reflectionProperty->hasDefaultValue(); $defaultValue = $hasDefaultValue ? $reflectionProperty->getDefaultValue() : null; @@ -94,6 +96,7 @@ className: $reflectionProperty->class, transformer: ($attributes->first(WithTransformer::class) ?? $attributes->first(WithCastAndTransformer::class))?->get(), inputMappedName: $inputMappedName, outputMappedName: $outputMappedName, + expandDotNotation: $expandDotNotation, attributes: $attributes, ); } diff --git a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php index 27208836a..0abbf4ffb 100644 --- a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php +++ b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php @@ -2,6 +2,7 @@ namespace Spatie\LaravelData\Support\TypeScriptTransformer; +use Illuminate\Support\Arr; use phpDocumentor\Reflection\Fqsen; use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\Array_; @@ -55,9 +56,9 @@ protected function transformProperties( $isOptional = $dataClass->attributes->has(TypeScriptOptional::class); - return array_reduce( + $results = array_reduce( $this->resolveProperties($class), - function (string $carry, ReflectionProperty $property) use ($isOptional, $dataClass, $missingSymbols) { + function (array $carry, ReflectionProperty $property) use ($isOptional, $dataClass, $missingSymbols) { /** @var \Spatie\LaravelData\Support\DataProperty $dataProperty */ $dataProperty = $dataClass->properties[$property->getName()]; @@ -88,16 +89,26 @@ function (string $carry, ReflectionProperty $property) use ($isOptional, $dataCl $propertyName = $dataProperty->outputMappedName ?? $dataProperty->name; - if (! preg_match('/^[$_a-zA-Z][$_a-zA-Z0-9]*$/', $propertyName)) { - $propertyName = "'{$propertyName}'"; + if ($dataProperty->expandDotNotation && str_contains($propertyName, '.')) { + Arr::set( + $carry, + $propertyName, + $isOptional + ? "?: {$transformed}" + : ": {$transformed}", + ); + } else { + $carry[$propertyName] = $isOptional + ? "?: {$transformed}" + : ": {$transformed}"; } - return $isOptional - ? "{$carry}{$propertyName}?: {$transformed};".PHP_EOL - : "{$carry}{$propertyName}: {$transformed};".PHP_EOL; + return $carry; }, - '' + [] ); + + return $this->arrayToTypeScript($results); } protected function resolveTypeForProperty( @@ -189,4 +200,23 @@ protected function cursorPaginatedCollectionType(string $class): Type ]), ]); } + + protected function arrayToTypeScript(array $array): string + { + $carry = ''; + + foreach ($array as $propertyName => $value) { + if (! preg_match('/^[$_a-zA-Z][$_a-zA-Z0-9]*$/', $propertyName)) { + $propertyName = "'{$propertyName}'"; + } + + if (is_array($value)) { + $carry .= "{$propertyName}: {".PHP_EOL.$this->arrayToTypeScript($value).'};'.PHP_EOL; + } else { + $carry .= "{$propertyName}{$value};".PHP_EOL; + } + } + + return $carry; + } } diff --git a/tests/DottedMappingTest.php b/tests/DottedMappingTest.php new file mode 100644 index 000000000..a74081873 --- /dev/null +++ b/tests/DottedMappingTest.php @@ -0,0 +1,172 @@ + 'never'], + ['dotted.description' => 'gonna'], + ['dotted.description' => 'give'], + ['dotted.description' => 'you'], + ['dotted' => ['description' => 'up']], + ]); + + $dataClass = new class ('hello', $data, $data, $dataCollection, $dataCollection) extends Data { + public function __construct( + #[MapOutputName('dotted.property')] + public string $string, + public SimpleDataWithMappedDottedProperty $nested, + #[MapOutputName('dotted.nested_other')] + public SimpleDataWithMappedDottedProperty $nested_renamed, + #[DataCollectionOf(SimpleDataWithMappedDottedProperty::class)] + public array $nested_collection, + #[ + MapOutputName('dotted.nested_other_collection'), + DataCollectionOf(SimpleDataWithMappedDottedProperty::class) + ] + public array $nested_renamed_collection, + ) { + } + }; + + expect($dataClass->toArray())->toMatchArray([ + 'dotted.property' => 'hello', + 'nested' => [ + 'dotted.description' => 'hello', + ], + 'dotted.nested_other' => [ + 'dotted.description' => 'hello', + ], + 'nested_collection' => [ + ['dotted.description' => 'never'], + ['dotted.description' => 'gonna'], + ['dotted.description' => 'give'], + ['dotted.description' => 'you'], + ['dotted.description' => 'up'], + ], + 'dotted.nested_other_collection' => [ + ['dotted.description' => 'never'], + ['dotted.description' => 'gonna'], + ['dotted.description' => 'give'], + ['dotted.description' => 'you'], + ['dotted.description' => 'up'], + ], + ]); +}); + +it('can map dotted property names when transforming with expand', function () { + $data = new SimpleDataWithExpandedDottedProperty('hello'); + $dataCollection = SimpleDataWithExpandedDottedProperty::collect([ + ['dotted.description' => 'never'], + ['dotted.description' => 'gonna'], + ['dotted.description' => 'give'], + ['dotted.description' => 'you'], + ['dotted' => ['description' => 'up']], + ]); + + $dataClass = new class ('hello', $data, $data, $dataCollection, $dataCollection) extends Data { + public function __construct( + #[MapDotExpandedOutputName('dotted.property')] + public string $string, + public SimpleDataWithExpandedDottedProperty $nested, + #[MapDotExpandedOutputName('dotted.nested_other')] + public SimpleDataWithExpandedDottedProperty $nested_renamed, + #[DataCollectionOf(SimpleDataWithExpandedDottedProperty::class)] + public array $nested_collection, + #[ + MapDotExpandedOutputName('dotted.nested_other_collection'), + DataCollectionOf(SimpleDataWithExpandedDottedProperty::class) + ] + public array $nested_renamed_collection, + ) { + } + }; + + expect($dataClass->toArray())->toMatchArray([ + 'dotted' => [ + 'property' => 'hello', + 'nested_other' => ['dotted' => ['description' => 'hello']], + 'nested_other_collection' => [ + ['dotted' => ['description' => 'never']], + ['dotted' => ['description' => 'gonna']], + ['dotted' => ['description' => 'give']], + ['dotted' => ['description' => 'you']], + ['dotted' => ['description' => 'up']], + ], + ], + 'nested' => [ + 'dotted' => ['description' => 'hello'], + ], + 'nested_collection' => [ + ['dotted' => ['description' => 'never']], + ['dotted' => ['description' => 'gonna']], + ['dotted' => ['description' => 'give']], + ['dotted' => ['description' => 'you']], + ['dotted' => ['description' => 'up']], + ], + ]); +}); + +it('can use all four attribute combinations for dot notation expansion', function () { + // Test all four combinations for OUTPUT transformation + $dataClass = new class ('value1', 'value2', 'value3', 'value4') extends Data { + public function __construct( + #[MapDotExpandedOutputName('user.profile.name')] + public string $property1, + #[MapDotExpandedName('user.profile.email')] + public string $property2, + #[MapOutputName('user.settings.theme', expandDotNotation: true)] + public string $property3, + #[MapName('user.settings.language', expandDotNotation: true)] + public string $property4, + ) { + } + }; + + // Test output transformation - all four should expand dot notation + expect($dataClass->toArray())->toMatchArray([ + 'user' => [ + 'profile' => [ + 'name' => 'value1', + 'email' => 'value2', + ], + 'settings' => [ + 'theme' => 'value3', + 'language' => 'value4', + ], + ], + ]); + + // Test INPUT mapping with MapDotExpandedName and MapName (both handle input) + $inputTestClass = new class ('default1', 'default2') extends Data { + public function __construct( + #[MapDotExpandedName('user.profile.email')] + public string $email, + #[MapName('user.settings.language', expandDotNotation: true)] + public string $language, + ) { + } + }; + + $fromArray = $inputTestClass::from([ + 'user' => [ + 'profile' => [ + 'email' => 'test@example.com', + ], + 'settings' => [ + 'language' => 'fr', + ], + ], + ]); + + expect($fromArray->email)->toBe('test@example.com'); + expect($fromArray->language)->toBe('fr'); +}); diff --git a/tests/Fakes/SimpleDataWithExpandedDottedProperty.php b/tests/Fakes/SimpleDataWithExpandedDottedProperty.php new file mode 100644 index 000000000..01624857f --- /dev/null +++ b/tests/Fakes/SimpleDataWithExpandedDottedProperty.php @@ -0,0 +1,15 @@ +canTransform($reflection))->toBeTrue(); assertMatchesSnapshot($transformer->transform($reflection, 'DataObject')->transformed); }); + +it('handles dotted property names without expand', function () { + $config = TypeScriptTransformerConfig::create(); + + $data = new class ('hello', 'world') extends Data { + public function __construct( + #[MapOutputName('user.name')] + public string $userName, + #[MapOutputName('user.profile.bio')] + public string $userBio, + ) { + } + }; + + $transformer = new DataTypeScriptTransformer($config); + $reflection = new ReflectionClass($data); + + expect($transformer->canTransform($reflection))->toBeTrue(); + $this->assertEquals( + <<transform($reflection, 'DataObject')->transformed + ); +}); + +it('handles dotted property names with expand', function () { + $config = TypeScriptTransformerConfig::create(); + + $data = new class ('hello', 'world', 'description') extends Data { + public function __construct( + #[MapDotExpandedOutputName('user.name')] + public string $userName, + #[MapName('user.profile.bio', expandDotNotation: true)] + public string $userBio, + #[MapOutputName('user.profile.description', expandDotNotation: true)] + public string $userDescription, + ) { + } + }; + + $transformer = new DataTypeScriptTransformer($config); + $reflection = new ReflectionClass($data); + + expect($transformer->canTransform($reflection))->toBeTrue(); + $this->assertEquals( + <<transform($reflection, 'DataObject')->transformed + ); +});