diff --git a/CHANGELOG.md b/CHANGELOG.md index dc270f31d..f2465f4ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,13 @@ All notable changes to `laravel-data` will be documented in this file. - Added contexts to the creation and transformation process - Allow creating a data object or collection using a factory - Speed up the process of creating and transforming data objects +- Add support for BNF syntax - Rewritten docs **Some more "internal" changes** - Restructured tests for the future we have ahead +- The Type system was completely rewritten, allowing for a better performance and more flexibility in the future - Benchmarks added to make data even faster ## 3.11.0 - 2023-12-21 diff --git a/docs/as-a-data-transfer-object/collections.md b/docs/as-a-data-transfer-object/collections.md index df1a492eb..e0fe6cf9e 100644 --- a/docs/as-a-data-transfer-object/collections.md +++ b/docs/as-a-data-transfer-object/collections.md @@ -232,9 +232,9 @@ SongData::collect(Song::all(), DataCollection::class)->first(); // SongData obje In previous versions of the package it was possible to use the `collection` method to create a collection of data objects: ```php -SongData::collection(Song::all()); // returns a DataCollection of SongData objects -SongData::collection(Song::paginate()); // returns a PaginatedDataCollection of SongData objects -SongData::collection(Song::cursorPaginate()); // returns a CursorPaginatedCollection of SongData objects +SongData::collect(Song::all()); // returns a DataCollection of SongData objects +SongData::collect(Song::paginate()); // returns a PaginatedDataCollection of SongData objects +SongData::collect(Song::cursorPaginate()); // returns a CursorPaginatedCollection of SongData objects ``` This method was removed with version v4 of the package in favor for the more powerful `collect` method. The `collection` method can still be used by using the `WithDeprecatedCollectionMethod` trait: diff --git a/docs/as-a-data-transfer-object/creating-a-data-object.md b/docs/as-a-data-transfer-object/creating-a-data-object.md index 905eb84d2..a097ed27a 100644 --- a/docs/as-a-data-transfer-object/creating-a-data-object.md +++ b/docs/as-a-data-transfer-object/creating-a-data-object.md @@ -1,5 +1,5 @@ --- -title: Creating a data object +title: Creating a data object weight: 1 --- @@ -52,7 +52,8 @@ Data can also be created from JSON strings: SongData::from('{"title" : "Never Gonna Give You Up","artist" : "Rick Astley"}'); ``` -Although the PHP 8.0 constructor properties look great in data objects, it is perfectly valid to use regular properties without a constructor like so: +Although the PHP 8.0 constructor properties look great in data objects, it is perfectly valid to use regular properties +without a constructor like so: ```php class SongData extends Data @@ -160,7 +161,8 @@ will try to create itself from the following types: - An *Arrayable* by calling `toArray` on it - An *array* -This list can be extended using extra normalizers, find more about it [here](https://spatie.be/docs/laravel-data/v4/advanced-usage/normalizers). +This list can be extended using extra normalizers, find more about +it [here](https://spatie.be/docs/laravel-data/v4/advanced-usage/normalizers). When a data object cannot be created using magical methods or the default methods, a `CannotCreateData` exception will be thrown. @@ -187,4 +189,26 @@ SongData::withoutMagicalCreationFrom($song); ## Advanced creation using factories -It is possible to configure how a data object is created, whether it will be validated, which casts to use and more. You can read more about it [here](/docs/laravel-data/v4/advanced-usage/factories). +It is possible to configure how a data object is created, whether it will be validated, which casts to use and more. You +can read more about it [here](/docs/laravel-data/v4/advanced-usage/factories). + +## DTO classes + +The default `Data` class from which you extend your data objects is a multi versatile class, it packs a lot of +functionality. But sometimes you just want a simple DTO class. You can use the `Dto` class for this: + +```php +class SongData extends Dto +{ + public function __construct( + public string $title, + public string $artist, + ) { + } +} +``` + +The `Dto` class is a data class in its most basic form. It can br created from anything using magical methods, can +validate payloads before creating the data object and can be created using factories. But it doesn't have any of the +other functionality that the `Data` class has. + diff --git a/docs/as-a-resource/from-data-to-resource.md b/docs/as-a-resource/from-data-to-resource.md index efbe389fe..29a806b3f 100644 --- a/docs/as-a-resource/from-data-to-resource.md +++ b/docs/as-a-resource/from-data-to-resource.md @@ -170,7 +170,25 @@ SongData::empty([ ]); ``` - ## Response status code +## Response status code When a resource is being returned from a controller, the status code of the response will automatically be set to `201 CREATED` when Laravel data detects that the request's method is `POST`. In all other cases, `200 OK` will be returned. +## Resource classes + +To make it a bit more clear that a data object is a resource, you can use the `Resource` class instead of the `Data` class: + +```php +use Spatie\LaravelData\Resource; + +class SongResource extends Resource +{ + public function __construct( + public string $title, + public string $artist, + ) { + } +} +``` + +These resource classes have as an advantage that they won't validate data or check authorization, They are only used to transform data which makes them a bit faster. diff --git a/docs/as-a-resource/lazy-properties.md b/docs/as-a-resource/lazy-properties.md index d812b5482..e491f2bba 100644 --- a/docs/as-a-resource/lazy-properties.md +++ b/docs/as-a-resource/lazy-properties.md @@ -8,10 +8,12 @@ Sometimes you don't want all the properties included when transforming a data ob ```php class AlbumData extends Data { + /** + * @param Collection $songs + */ public function __construct( public string $title, - #[DataCollectionOf(SongData::class)] - public DataCollection $songs, + public Collection $songs, ) { } } @@ -22,10 +24,12 @@ This will always output a collection of songs, which can become quite large. Wit ```php class AlbumData extends Data { + /** + * @param Lazy|Collection $songs + */ public function __construct( public string $title, - #[DataCollectionOf(SongData::class)] - public Lazy|DataCollection $songs, + public Lazy|Collection $songs, ) { } @@ -33,7 +37,7 @@ class AlbumData extends Data { return new self( $album->title, - Lazy::create(fn() => SongData::collection($album->songs)) + Lazy::create(fn() => SongData::collect($album->songs)) ); } } @@ -47,15 +51,15 @@ Now when we transform the data object as such: AlbumData::from(Album::first())->toArray(); ``` -We get the following JSON: +We get the following array: -```json -{ - "name": "Together Forever" -} +```php +[ + 'title' => 'Together Forever', +] ``` -As you can see, the `songs` property is missing in the JSON output. Here's how you can include it. +As you can see, the `songs` property is missing in the array output. Here's how you can include it. ```php AlbumData::from(Album::first())->include('songs'); @@ -63,7 +67,7 @@ AlbumData::from(Album::first())->include('songs'); ## Including lazy properties -Properties will only be included when the `include` method is called on the data object with the property's name. +Lazy properties will only be included when the `include` method is called on the data object with the property's name. It is also possible to nest these includes. For example, let's update the `SongData` class and make all of its properties lazy: @@ -138,7 +142,7 @@ return UserData::from(Auth::user())->include('favorite_song.name'); You can include lazy properties in different ways: ```php -Lazy::create(fn() => SongData::collection($album->songs)); +Lazy::create(fn() => SongData::collect($album->songs)); ``` With a basic `Lazy` property, you must explicitly include it when the data object is transformed. @@ -146,7 +150,7 @@ With a basic `Lazy` property, you must explicitly include it when the data objec Sometimes you only want to include a property when a specific condition is true. This can be done with conditional lazy properties: ```php -Lazy::when(fn() => $this->is_admin, fn() => SongData::collection($album->songs)); +Lazy::when(fn() => $this->is_admin, fn() => SongData::collect($album->songs)); ``` The property will only be included when the `is_admin` property of the data object is true. It is not possible to include the property later on with the `include` method when a condition is not accepted. @@ -156,7 +160,7 @@ The property will only be included when the `is_admin` property of the data obje You can also only include a lazy property when a particular relation is loaded on the model as such: ```php -Lazy::whenLoaded('songs', $album, fn() => SongData::collection($album->songs)); +Lazy::whenLoaded('songs', $album, fn() => SongData::collect($album->songs)); ``` Now the property will only be included when the song's relation is loaded on the model. @@ -166,7 +170,7 @@ Now the property will only be included when the song's relation is loaded on the It is possible to mark a lazy property as included by default: ```php -Lazy::create(fn() => SongData::collection($album->songs))->defaultIncluded(); +Lazy::create(fn() => SongData::collect($album->songs))->defaultIncluded(); ``` The property will now always be included when the data object is transformed. You can explicitly exclude properties that were default included as such: @@ -225,10 +229,12 @@ In some cases you may want to define an include on a class level by implementing ```php class AlbumData extends Data { + /** + * @param Lazy|Collection $songs + */ public function __construct( public string $title, - #[DataCollectionOf(SongData::class)] - public Lazy|DataCollection $songs, + public Lazy|Collection $songs, ) { } @@ -246,10 +252,12 @@ It is even possible to include nested properties: ```php class AlbumData extends Data { + /** + * @param Lazy|Collection $songs + */ public function __construct( public string $title, - #[DataCollectionOf(SongData::class)] - public Lazy|DataCollection $songs, + public Lazy|Collection $songs, ) { } @@ -378,3 +386,52 @@ It is also possible to run exclude, except and only operations on a data object: - You can define **except** in `allowedRequestExcept` and use the `except` key in your query string - You can define **only** in `allowedRequestOnly` and use the `only` key in your query string +## Mutability + +Adding includes/excludes/only/except to a data object will only affect the data object (and its nested chain) once: + +```php +AlbumData::from(Album::first())->include('songs')->toArray(); // will include songs +AlbumData::from(Album::first())->toArray(); // will not include songs +``` + +If you want to add includes/excludes/only/except to a data object and its nested chain that will be used for all future transformations, you can define them in their respective *properties methods: + +```php +class AlbumData extends Data +{ + /** + * @param Lazy|Collection $songs + */ + public function __construct( + public string $title, + public Lazy|Collection $songs, + ) { + } + + public function includeProperties(): array + { + return [ + 'songs' + ]; + } +} +``` + +Or use the permanent methods: + +```php +AlbumData::from(Album::first())->includePermanently('songs'); +AlbumData::from(Album::first())->excludePermanently('songs'); +AlbumData::from(Album::first())->onlyPermanently('songs'); +AlbumData::from(Album::first())->exceptPermanently('songs'); +``` + +When using conditional a includes/excludes/only/except, you can set the permanent flag: + +```php +AlbumData::from(Album::first())->includeWhen('songs', fn(AlbumData $data) => count($data->songs) > 0, permanent: true); +AlbumData::from(Album::first())->excludeWhen('songs', fn(AlbumData $data) => count($data->songs) > 0, permanent: true); +AlbumData::from(Album::first())->onlyWhen('songs', fn(AlbumData $data) => count($data->songs) > 0), permanent: true); +AlbumData::from(Album::first())->except('songs', fn(AlbumData $data) => count($data->songs) > 0, permanent: true); +``` diff --git a/docs/as-a-resource/transformers.md b/docs/as-a-resource/transformers.md index 988184bff..779b4aef3 100644 --- a/docs/as-a-resource/transformers.md +++ b/docs/as-a-resource/transformers.md @@ -3,11 +3,11 @@ title: Transforming data weight: 7 --- -Each property of a data object should be transformed into a usable type to communicate via JSON. +Transformers allow you to transform complex types to simple types. This is useful when you want to transform a data object to an array or JSON. No complex transformations are required for the default types (string, bool, int, float, enum and array), but special types like `Carbon` or a Laravel Model will need extra attention. -Transformers are simple classes that will convert a complex type to something simple like a `string` or `int`. For example, we can transform a `Carbon` object to `16-05-1994`, `16-05-1994T00:00:00+00` or something completely different. +Transformers are simple classes that will convert a such complex types to something simple like a `string` or `int`. For example, we can transform a `Carbon` object to `16-05-1994`, `16-05-1994T00:00:00+00` or something completely different. There are two ways you can define transformers: locally and globally. @@ -80,16 +80,65 @@ ArtistData::from($artist)->all(); ## Getting a data object (on steroids) -Internally the package uses the `transform` method for operations like `toArray`, `all`, `toJson` and so on. This method is highly configurable: +Internally the package uses the `transform` method for operations like `toArray`, `all`, `toJson` and so on. This method is highly configurable, when calling it without any arguments it will behave like the `toArray` method: ```php +ArtistData::from($artist)->transform(); +``` + +Producing the following result: + +```php +[ + 'name' => 'Rick Astley', + 'birth_date' => '06-02-1966', +] +``` + +It is possible to disable the transformation of values, which will make the `transform` method behave like the `all` method: + +```php +use Spatie\LaravelData\Support\Transformation\TransformationContext; + ArtistData::from($artist)->transform( - bool $transformValues = true, - WrapExecutionType $wrapExecutionType = WrapExecutionType::Disabled, - bool $mapPropertyNames = true, + TransformationContext::create()->withoutTransformingValues() ); ``` -- **$transformValues** when enabled transformers will be used to transform properties, also data objects and collections will be transformed -- **$wrapExecutionType** allows you to set if wrapping is `Enabled` or `Disabled` -- **$mapPropertyNames** uses defined mappers to rename properties when enabled +Outputting the following array: + +```php +[ + 'name' => 'Rick Astley', + 'birth_date' => Carbon::parse('06-02-1966'), +] +``` + +The [mapping of property names](/docs/laravel-data/v4/as-a-resource/mapping-property-names) can also be disabled: + +```php +ArtistData::from($artist)->transform( + TransformationContext::create()->mapPropertyNames(false) +); +``` + +It is possible to enable [wrapping](/docs/laravel-data/v4/as-a-resource/wrapping-data) the data object: + +```php +use Spatie\LaravelData\Support\Wrapping\WrapExecutionType; + +ArtistData::from($artist)->transform( + TransformationContext::create()->wrapExecutionType(WrapExecutionType::Enabled) +); +``` + +Outputting the following array: + +```php +[ + 'data' => [ + 'name' => 'Rick Astley', + 'birth_date' => '06-02-1966', + ], +] +``` diff --git a/src/Resource.php b/src/Resource.php index 5358c12d8..2cb81da97 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -17,6 +17,12 @@ use Spatie\LaravelData\Contracts\ResponsableData as ResponsableDataContract; use Spatie\LaravelData\Contracts\TransformableData as TransformableDataContract; use Spatie\LaravelData\Contracts\WrappableData as WrappableDataContract; +use Spatie\LaravelData\DataPipes\AuthorizedDataPipe; +use Spatie\LaravelData\DataPipes\CastPropertiesDataPipe; +use Spatie\LaravelData\DataPipes\DefaultValuesDataPipe; +use Spatie\LaravelData\DataPipes\FillRouteParameterPropertiesDataPipe; +use Spatie\LaravelData\DataPipes\MapPropertiesDataPipe; +use Spatie\LaravelData\DataPipes\ValidatePropertiesDataPipe; class Resource implements BaseDataContract, AppendableDataContract, IncludeableDataContract, ResponsableDataContract, TransformableDataContract, WrappableDataContract, EmptyDataContract { @@ -28,4 +34,14 @@ class Resource implements BaseDataContract, AppendableDataContract, IncludeableD use WrappableData; use EmptyData; use ContextableData; + + public static function pipeline(): DataPipeline + { + return DataPipeline::create() + ->into(static::class) + ->through(MapPropertiesDataPipe::class) + ->through(FillRouteParameterPropertiesDataPipe::class) + ->through(DefaultValuesDataPipe::class) + ->through(CastPropertiesDataPipe::class); + } } diff --git a/src/Support/Partials/ForwardsToPartialsDefinition.php b/src/Support/Partials/ForwardsToPartialsDefinition.php index 54b401fbb..988ed7be0 100644 --- a/src/Support/Partials/ForwardsToPartialsDefinition.php +++ b/src/Support/Partials/ForwardsToPartialsDefinition.php @@ -27,6 +27,17 @@ public function include(string ...$includes): static return $this; } + public function includePermanently(string ...$includes): static + { + $partialsCollection = $this->getPartialsContainer()->includePartials ??= new PartialsCollection(); + + foreach ($includes as $include) { + $partialsCollection->attach(Partial::create($include, permanent: true)); + } + + return $this; + } + public function exclude(string ...$excludes): static { $partialsCollection = $this->getPartialsContainer()->excludePartials ??= new PartialsCollection(); @@ -38,6 +49,17 @@ public function exclude(string ...$excludes): static return $this; } + public function excludePermanently(string ...$excludes): static + { + $partialsCollection = $this->getPartialsContainer()->excludePartials ??= new PartialsCollection(); + + foreach ($excludes as $exclude) { + $partialsCollection->attach(Partial::create($exclude, permanent: true)); + } + + return $this; + } + public function only(string ...$only): static { $partialsCollection = $this->getPartialsContainer()->onlyPartials ??= new PartialsCollection(); @@ -49,6 +71,17 @@ public function only(string ...$only): static return $this; } + public function onlyPermanently(string ...$only): static + { + $partialsCollection = $this->getPartialsContainer()->onlyPartials ??= new PartialsCollection(); + + foreach ($only as $onlyDefinition) { + $partialsCollection->attach(Partial::create($onlyDefinition, permanent: true)); + } + + return $this; + } + public function except(string ...$except): static { $partialsCollection = $this->getPartialsContainer()->exceptPartials ??= new PartialsCollection(); @@ -60,53 +93,64 @@ public function except(string ...$except): static return $this; } - public function includeWhen(string $include, bool|Closure $condition): static + public function exceptPermanently(string ...$except): static + { + $partialsCollection = $this->getPartialsContainer()->exceptPartials ??= new PartialsCollection(); + + foreach ($except as $exceptDefinition) { + $partialsCollection->attach(Partial::create($exceptDefinition, permanent: true)); + } + + return $this; + } + + public function includeWhen(string $include, bool|Closure $condition, bool $permanent = false): static { $partialsCollection = $this->getPartialsContainer()->includePartials ??= new PartialsCollection(); if (is_callable($condition)) { - $partialsCollection->attach(Partial::createConditional($include, $condition)); + $partialsCollection->attach(Partial::createConditional($include, $condition, permanent: $permanent)); } elseif ($condition === true) { - $partialsCollection->attach(Partial::create($include)); + $partialsCollection->attach(Partial::create($include, permanent: $permanent)); } return $this; } - public function excludeWhen(string $exclude, bool|Closure $condition): static + public function excludeWhen(string $exclude, bool|Closure $condition, bool $permanent = false): static { $partialsCollection = $this->getPartialsContainer()->excludePartials ??= new PartialsCollection(); if (is_callable($condition)) { - $partialsCollection->attach(Partial::createConditional($exclude, $condition)); + $partialsCollection->attach(Partial::createConditional($exclude, $condition, permanent: $permanent)); } elseif ($condition === true) { - $partialsCollection->attach(Partial::create($exclude)); + $partialsCollection->attach(Partial::create($exclude, permanent: $permanent)); } return $this; } - public function onlyWhen(string $only, bool|Closure $condition): static + public function onlyWhen(string $only, bool|Closure $condition, bool $permanent = false): static { $partialsCollection = $this->getPartialsContainer()->onlyPartials ??= new PartialsCollection(); if (is_callable($condition)) { - $partialsCollection->attach(Partial::createConditional($only, $condition)); + $partialsCollection->attach(Partial::createConditional($only, $condition, permanent: $permanent)); } elseif ($condition === true) { - $partialsCollection->attach(Partial::create($only)); + $partialsCollection->attach(Partial::create($only, permanent: $permanent)); } return $this; } - public function exceptWhen(string $except, bool|Closure $condition): static + public function exceptWhen(string $except, bool|Closure $condition, bool $permanent = false): static { $partialsCollection = $this->getPartialsContainer()->exceptPartials ??= new PartialsCollection(); if (is_callable($condition)) { - $partialsCollection->attach(Partial::createConditional($except, $condition)); + $partialsCollection->attach(Partial::createConditional($except, $condition, permanent: $permanent)); } elseif ($condition === true) { - $partialsCollection->attach(Partial::create($except)); + $partialsCollection->attach(Partial::create($except, permanent: $permanent)); } return $this; diff --git a/src/Support/Types/Storage/AcceptedTypesStorage.php b/src/Support/Types/Storage/AcceptedTypesStorage.php index 31fb773fb..91ca9d5b7 100644 --- a/src/Support/Types/Storage/AcceptedTypesStorage.php +++ b/src/Support/Types/Storage/AcceptedTypesStorage.php @@ -41,7 +41,7 @@ public static function getAcceptedTypes(string $name): array /** @return string[] */ protected static function resolveAcceptedTypes(string $name): array { - if (! class_exists($name)) { + if (! class_exists($name) && ! interface_exists($name)) { return []; } diff --git a/tests/PartialsTest.php b/tests/PartialsTest.php index 1a432d177..0212e9739 100644 --- a/tests/PartialsTest.php +++ b/tests/PartialsTest.php @@ -978,6 +978,66 @@ protected function includeProperties(): array ]); }); +it('can define permanent partials using function call', function ( + Data $data, + Closure $temporaryPartial, + Closure $permanentPartial, + array $expectedFullPayload, + array $expectedPartialPayload +) { + $data = $temporaryPartial($data); + + expect($data->toArray())->toBe($expectedPartialPayload); + expect($data->toArray())->toBe($expectedFullPayload); + + $data = $permanentPartial($data); + + expect($data->toArray())->toBe($expectedPartialPayload); + expect($data->toArray())->toBe($expectedPartialPayload); +})->with(function (){ + yield [ + 'data' => new LazyData( + Lazy::create(fn () => 'Rick Astley'), + ), + 'temporaryPartial' => fn(LazyData $data) => $data->include('name'), + 'permanentPartial' => fn(LazyData $data) => $data->includePermanently('name'), + 'expectedFullPayload' => [], + 'expectedPartialPayload' => ['name' => 'Rick Astley'], + ]; + + yield [ + 'data' => new LazyData( + Lazy::create(fn () => 'Rick Astley')->defaultIncluded(), + ), + 'temporaryPartial' => fn(LazyData $data) => $data->exclude('name'), + 'permanentPartial' => fn(LazyData $data) => $data->excludePermanently('name'), + 'expectedFullPayload' => ['name' => 'Rick Astley'], + 'expectedPartialPayload' => [], + ]; + + yield [ + 'data' => new MultiData( + 'Rick Astley', + 'Never gonna give you up', + ), + 'temporaryPartial' => fn(MultiData $data) => $data->only('first'), + 'permanentPartial' => fn(MultiData $data) => $data->onlyPermanently('first'), + 'expectedFullPayload' => ['first' => 'Rick Astley', 'second' => 'Never gonna give you up'], + 'expectedPartialPayload' => ['first' => 'Rick Astley'], + ]; + + yield [ + 'data' => new MultiData( + 'Rick Astley', + 'Never gonna give you up', + ), + 'temporaryPartial' => fn(MultiData $data) => $data->except('first'), + 'permanentPartial' => fn(MultiData $data) => $data->exceptPermanently('first'), + 'expectedFullPayload' => ['first' => 'Rick Astley', 'second' => 'Never gonna give you up'], + 'expectedPartialPayload' => ['second' => 'Never gonna give you up'], + ]; +}); + it('can set partials on a nested data object and these will be respected', function () { class TestMultiLazyNestedDataWithObjectAndCollection extends Data {