diff --git a/docs/custom-collections.md b/docs/advanced-usage/custom-collections.md similarity index 100% rename from docs/custom-collections.md rename to docs/advanced-usage/custom-collections.md diff --git a/docs/advanced-usage/mapping-rules.md b/docs/advanced-usage/mapping-rules.md new file mode 100644 index 00000000..cac5297c --- /dev/null +++ b/docs/advanced-usage/mapping-rules.md @@ -0,0 +1,53 @@ +--- +title: Mapping rules +weight: 13 +--- + +It is possible to map the names properties going in and out of your data objects using: `MapOutputName`, `MapInputName` +and `MapName` attributes. But sometimes it can be quite hard to follow where which name can be used. Let's go through +some case: + +In the data object: + +```php +class UserData extends Data + { + public function __construct( + #[MapName('favorite_song')] // name mapping + public Lazy|SongData $song, + #[RequiredWith('song')] // In validation rules, use the original name + public string $title, + ) { + } + + public static function allowedRequestExcept(): ?array + { + return [ + 'song' // Use the original name when defining includes, excludes, excepts and only + ]; + } + + // ... + } + ``` + +When creating a data object: + +```php +UserData::from([ + 'favorite_song' => ..., // You can use the mapped or original name here + 'title' => 'some title' +]); +``` + +When adding an include, exclude, except or only: + +```php + UserData::from(User::first())->except('song'); // Always use the original name here + ``` + +Within a request query, you can use the mapped or original name: + +``` +https://spatie.be/my-account?except[]=favorite_song +``` diff --git a/docs/advanced-usage/validation-attributes.md b/docs/advanced-usage/validation-attributes.md index fce9920b..5e84d6d1 100644 --- a/docs/advanced-usage/validation-attributes.md +++ b/docs/advanced-usage/validation-attributes.md @@ -1,6 +1,6 @@ --- title: Validation attributes -weight: 13 +weight: 14 --- It is possible to validate the request before a data object is constructed. This can be done by adding validation attributes to the properties of a data object like this: diff --git a/src/Resolvers/PartialsTreeFromRequestResolver.php b/src/Resolvers/PartialsTreeFromRequestResolver.php index 39130585..5c97f11e 100644 --- a/src/Resolvers/PartialsTreeFromRequestResolver.php +++ b/src/Resolvers/PartialsTreeFromRequestResolver.php @@ -10,6 +10,7 @@ use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Support\PartialsParser; use Spatie\LaravelData\Support\PartialTrees; +use Spatie\LaravelData\Support\PartialTreesMapping; use TypeError; class PartialsTreeFromRequestResolver @@ -25,29 +26,37 @@ public function execute( IncludeableData $data, Request $request, ): PartialTrees { + $dataClass = match (true) { + $data instanceof BaseData => $data::class, + $data instanceof BaseDataCollectable => $data->getDataClass(), + default => throw new TypeError('Invalid type of data') + }; + + $dataClass = $this->dataConfig->getDataClass($dataClass); + + $mapping = PartialTreesMapping::fromRootDataClass($dataClass); + $requestedIncludesTree = $this->partialsParser->execute( - $request->has('include') ? $this->arrayFromRequest($request, 'include') : [] + $request->has('include') ? $this->arrayFromRequest($request, 'include') : [], + $mapping ); $requestedExcludesTree = $this->partialsParser->execute( - $request->has('exclude') ? $this->arrayFromRequest($request, 'exclude') : [] + $request->has('exclude') ? $this->arrayFromRequest($request, 'exclude') : [], + $mapping ); $requestedOnlyTree = $this->partialsParser->execute( - $request->has('only') ? $this->arrayFromRequest($request, 'only') : [] + $request->has('only') ? $this->arrayFromRequest($request, 'only') : [], + $mapping ); $requestedExceptTree = $this->partialsParser->execute( - $request->has('except') ? $this->arrayFromRequest($request, 'except') : [] + $request->has('except') ? $this->arrayFromRequest($request, 'except') : [], + $mapping ); - $dataClass = match (true) { - $data instanceof BaseData => $data::class, - $data instanceof BaseDataCollectable => $data->getDataClass(), - default => throw new TypeError('Invalid type of data') - }; - - $allowedRequestIncludesTree = $this->allowedPartialsParser->execute('allowedRequestIncludes', $this->dataConfig->getDataClass($dataClass)); - $allowedRequestExcludesTree = $this->allowedPartialsParser->execute('allowedRequestExcludes', $this->dataConfig->getDataClass($dataClass)); - $allowedRequestOnlyTree = $this->allowedPartialsParser->execute('allowedRequestOnly', $this->dataConfig->getDataClass($dataClass)); - $allowedRequestExceptTree = $this->allowedPartialsParser->execute('allowedRequestExcept', $this->dataConfig->getDataClass($dataClass)); + $allowedRequestIncludesTree = $this->allowedPartialsParser->execute('allowedRequestIncludes', $dataClass); + $allowedRequestExcludesTree = $this->allowedPartialsParser->execute('allowedRequestExcludes', $dataClass); + $allowedRequestOnlyTree = $this->allowedPartialsParser->execute('allowedRequestOnly', $dataClass); + $allowedRequestExceptTree = $this->allowedPartialsParser->execute('allowedRequestExcept', $dataClass); $partialTrees = $data->getPartialTrees(); diff --git a/src/Support/PartialTreesMapping.php b/src/Support/PartialTreesMapping.php new file mode 100644 index 00000000..e06f3b2d --- /dev/null +++ b/src/Support/PartialTreesMapping.php @@ -0,0 +1,67 @@ + $children + */ + public function __construct( + readonly public string $original, + readonly public string $mapped, + readonly public array $children = [], + ) { + } + + public function getChild(string $mapped): ?PartialTreesMapping + { + foreach ($this->children as $child) { + if ($child->mapped === $mapped || $child->original === $mapped) { + return $child; + } + } + + return null; + } + + public static function fromRootDataClass(DataClass $dataClass): self + { + return self::fromDataClass('root', 'root', $dataClass); + } + + public static function fromDataClass( + string $original, + string $mapped, + DataClass $dataClass + ): self { + $children = []; + + $dataClass->properties->each(function (DataProperty $dataProperty) use (&$children) { + if ($dataProperty->type->isDataObject || $dataProperty->type->isDataCollectable) { + $children[] = self::fromDataClass( + $dataProperty->name, + $dataProperty->outputMappedName ?? $dataProperty->name, + app(DataConfig::class)->getDataClass($dataProperty->type->dataClass), + ); + + return; + } + + if ($dataProperty->outputMappedName === null) { + return; + } + + $children[] = new self( + $dataProperty->name, + $dataProperty->outputMappedName, + ); + }); + + return new self( + $original, + $mapped, + $children + ); + } +} diff --git a/src/Support/PartialsParser.php b/src/Support/PartialsParser.php index 5a548696..ba1bb949 100644 --- a/src/Support/PartialsParser.php +++ b/src/Support/PartialsParser.php @@ -11,7 +11,7 @@ class PartialsParser { - public function execute(array $partials): TreeNode + public function execute(array $partials, ?PartialTreesMapping $mapping = null): TreeNode { $nodes = new DisabledTreeNode(); @@ -28,6 +28,7 @@ public function execute(array $partials): TreeNode if (Str::startsWith($field, '{') && Str::endsWith($field, '}')) { $children = collect(explode(',', substr($field, 1, -1))) ->values() + ->map(fn (string $child) => $mapping?->getChild($child)?->original ?? $child) ->flip() ->map(fn () => new ExcludedTreeNode()) ->all(); @@ -37,12 +38,16 @@ public function execute(array $partials): TreeNode continue; } + $mappingForField = $mapping?->getChild($field); + $nestedNode = $nested === null ? new ExcludedTreeNode() - : $this->execute([$nested]); + : $this->execute([$nested], $mappingForField); + + $fieldName = $mappingForField?->original ?? $field; $nodes = $nodes->merge(new PartialTreeNode([ - $field => $nestedNode, + $fieldName => $nestedNode, ])); } diff --git a/tests/Fakes/SimpleChildDataWithMappedOutputName.php b/tests/Fakes/SimpleChildDataWithMappedOutputName.php new file mode 100644 index 00000000..6c45c202 --- /dev/null +++ b/tests/Fakes/SimpleChildDataWithMappedOutputName.php @@ -0,0 +1,21 @@ +resolver = resolve(PartialsTreeFromRequestResolver::class); @@ -213,3 +215,31 @@ public static function allowedRequestIncludes(): ?array 'expected' => ['artist', 'name'], ]; }); + +it('handles parsing except from request with mapped output name', function (array $input, array $expectedArray) { + $dataclass = SimpleDataWithMappedOutputName::from([ + 'id' => 1, + 'amount' => 1000, + 'any_string' => 'test', + 'child' => SimpleChildDataWithMappedOutputName::from([ + 'id' => 2, + 'amount' => 2000, + ]), + ]); + + $request = request()->merge($input); + + $data = $dataclass->toResponse($request)->getData(assoc: true); + + expect($data)->toMatchArray($expectedArray); +})->with(function () { + yield 'input as array' => [ + 'input' => ['except' => ['paid_amount', 'any_string', 'child.child_amount']], + 'expectedArray' => [ + 'id' => 1, + 'child' => [ + 'id' => 2, + ], + ], + ]; +}); diff --git a/tests/Support/PartialTreesMappingTest.php b/tests/Support/PartialTreesMappingTest.php new file mode 100644 index 00000000..4c97d80e --- /dev/null +++ b/tests/Support/PartialTreesMappingTest.php @@ -0,0 +1,83 @@ +getDataClass(SimpleData::class) + ); + + expect($mapping) + ->mapped->toBe('root') + ->original->toBe('root') + ->children->toBeEmpty(); +}); + +it('can create a mapping', function () { + $dataClass = new class () extends Data { + #[MapOutputName('naam')] + public string $name; + + #[MapOutputName('genest')] + public SimpleData $nested; + + public SimpleDataWithMappedOutputName $nested_with_mapping; + + public SimpleData $data_always_included; + }; + + $mapping = PartialTreesMapping::fromRootDataClass( + app(DataConfig::class)->getDataClass($dataClass::class) + ); + + expect($mapping) + ->mapped->toBe('root') + ->original->toBe('root') + ->children->toHaveCount(4); + + expect($mapping->children[0]) + ->mapped->toBe('naam') + ->original->toBe('name') + ->children->toHaveCount(0); + + expect($mapping->children[1]) + ->mapped->toBe('genest') + ->original->toBe('nested') + ->children->toHaveCount(0); + + expect($mapping->children[2]) + ->mapped->toBe('nested_with_mapping') + ->original->toBe('nested_with_mapping') + ->children->toHaveCount(4); + + expect($mapping->children[2]->children[0]) + ->mapped->toBe('id') + ->original->toBe('id'); + + expect($mapping->children[2]->children[1]) + ->mapped->toBe('paid_amount') + ->original->toBe('amount'); + + expect($mapping->children[2]->children[2]) + ->mapped->toBe('any_string') + ->original->toBe('anyString'); + + expect($mapping->children[2]->children[3]) + ->mapped->toBe('child') + ->original->toBe('child') + ->children->toHaveCount(1); + + expect($mapping->children[2]->children[3]->children[0]) + ->mapped->toBe('child_amount') + ->original->toBe('amount'); + + expect($mapping->children[3]) + ->mapped->toBe('data_always_included') + ->original->toBe('data_always_included') + ->children->toHaveCount(0); +}); diff --git a/tests/Support/PartialsParserTest.php b/tests/Support/PartialsParserTest.php index e6a0f0ea..66348983 100644 --- a/tests/Support/PartialsParserTest.php +++ b/tests/Support/PartialsParserTest.php @@ -1,6 +1,7 @@ [ @@ -184,3 +186,76 @@ function complexPartialsProvider(): Generator ]), ]; } + +it('can parse directives with mapping', function (array $partials, TreeNode $expected) { + $mapping = new PartialTreesMapping('root', 'root', [ + new PartialTreesMapping('name', 'naam'), + new PartialTreesMapping('age', 'leeftijd'), + new PartialTreesMapping('gender', 'geslacht'), + new PartialTreesMapping('struct', 'structuur', [ + new PartialTreesMapping('name', 'naam'), + new PartialTreesMapping('age', 'leeftijd'), + new PartialTreesMapping('gender', 'geslacht'), + ]), + ]); + + expect((new PartialsParser())) + ->execute($partials, $mapping) + ->toEqual($expected); +})->with(function () { + yield "empty" => [ + 'partials' => [], + 'expected' => new DisabledTreeNode(), + ]; + + yield "all mapped" => [ + 'partials' => [ + 'naam', + '{leeftijd, geslacht}', + 'structuur.naam', + 'structuur.{leeftijd, geslacht}', + ], + 'expected' => new PartialTreeNode([ + 'name' => new ExcludedTreeNode(), + 'age' => new ExcludedTreeNode(), + 'gender' => new ExcludedTreeNode(), + 'struct' => new PartialTreeNode([ + 'name' => new ExcludedTreeNode(), + 'age' => new ExcludedTreeNode(), + 'gender' => new ExcludedTreeNode(), + ]), + ]), + ]; + + yield "some mapped, some not + non defined mappings" => [ + 'partials' => [ + 'name', + 'bio', + '{leeftijd, gender}', + 'structuur.name', + 'struct.bio', + 'structuur.{leeftijd, gender}', + ], + 'expected' => new PartialTreeNode([ + 'name' => new ExcludedTreeNode(), + 'bio' => new ExcludedTreeNode(), + 'age' => new ExcludedTreeNode(), + 'gender' => new ExcludedTreeNode(), + 'struct' => new PartialTreeNode([ + 'name' => new ExcludedTreeNode(), + 'bio' => new ExcludedTreeNode(), + 'age' => new ExcludedTreeNode(), + 'gender' => new ExcludedTreeNode(), + ]), + ]), + ]; + + yield "star operator" => [ + 'partials' => [ + 'structuur.*', + ], + 'expected' => new PartialTreeNode([ + 'struct' => new AllTreeNode(), + ]), + ]; +});