Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/advanced-usage/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
}
```
2 changes: 2 additions & 0 deletions docs/as-a-data-transfer-object/mapping-property-names.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
50 changes: 50 additions & 0 deletions docs/as-a-resource/mapping-property-names.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions src/Attributes/MapDotExpandedName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Spatie\LaravelData\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
class MapDotExpandedName extends MapName
{
public function __construct(string|int $input, string|int|null $output = null)
{
parent::__construct($input, $output, expandDotNotation: true);
}
}
14 changes: 14 additions & 0 deletions src/Attributes/MapDotExpandedOutputName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Spatie\LaravelData\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
class MapDotExpandedOutputName extends MapOutputName
{
public function __construct(string|int $output)
{
parent::__construct($output, expandDotNotation: true);
}
}
7 changes: 5 additions & 2 deletions src/Attributes/MapName.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
class MapName
{
public function __construct(public string|int|NameMapper $input, public string|int|NameMapper|null $output = null)
{
public function __construct(
public string|int|NameMapper $input,
public string|int|NameMapper|null $output = null,
public bool $expandDotNotation = false,
) {
$this->output ??= $this->input;
}
}
6 changes: 4 additions & 2 deletions src/Attributes/MapOutputName.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
}
11 changes: 8 additions & 3 deletions src/Resolvers/EmptyDataResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/Resolvers/NameMappersResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public function execute(
return [
'inputNameMapper' => $this->resolveInputNameMapper($attributes),
'outputNameMapper' => $this->resolveOutputNameMapper($attributes),
'expandDotNotation' => $this->resolveExpandDotNotation($attributes),
];
}

Expand Down Expand Up @@ -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;
}
}
7 changes: 6 additions & 1 deletion src/Resolvers/TransformedDataResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/Support/DataProperty.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
Expand Down
3 changes: 3 additions & 0 deletions src/Support/Factories/DataPropertyFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ public function build(
default => null,
};

$expandDotNotation = $mappers['expandDotNotation'];

if (! $reflectionProperty->isPromoted()) {
$hasDefaultValue = $reflectionProperty->hasDefaultValue();
$defaultValue = $hasDefaultValue ? $reflectionProperty->getDefaultValue() : null;
Expand Down Expand Up @@ -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,
);
}
Expand Down
46 changes: 38 additions & 8 deletions src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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_;
Expand Down Expand Up @@ -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()];

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
}
Loading