diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index eb537d8c4..2cb1d5e16 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -13,7 +13,7 @@ jobs: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2.2.0 + uses: dependabot/fetch-metadata@v2.3.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 3a618aa31..a23ac703e 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -21,7 +21,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.4' coverage: none - name: Install composer dependencies diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3eedbbe6c..21b48aa14 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -9,7 +9,7 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest] - php: [8.1, 8.2, 8.3] + php: [8.1, 8.2, 8.3, 8.4] laravel: [10.*, 11.*] stability: [prefer-lowest, prefer-stable] include: @@ -20,7 +20,8 @@ jobs: exclude: - php: 8.1 laravel: 11.* - + - php: 8.4 + laravel: 10.* name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index fe98f1122..9919183de 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ node_modules .php-cs-fixer.cache .phpbench .DS_Store +/.phpunit.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d8eb232..52890f551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,103 @@ All notable changes to `laravel-data` will be documented in this file. +## 4.13.0 - 2025-01-24 + +### What's Changed + +* Auto lazy by @rubenvanassche in https://github.com/spatie/laravel-data/pull/831 + +**Full Changelog**: https://github.com/spatie/laravel-data/compare/4.12.0...4.13.0 + +## 4.12.0 - 2025-01-24 + +What a release! Probably to biggest minor release we've ever done! + +Some cool highlights: + +#### Disabling optional values + +Optional values are great, but sometimes a `null` value is desirable from now on you can do the following: + +```php +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` + + +``` +#### Injecting property values + +It was already possible to inject a Laravel route parameter when creating a data object, we've now extended this functionality quite a bit and also allow injecting dependencies from the container and the authenticated user. + +```php +class SongData extends Data { + #[FromAuthenticatedUser] + public UserData $user; +} + + +``` +#### Merging manual rules + +In the past when the validation rules of a property were manually defined, the automatic validation rules for that property were omitted. From now on, you can define manual validation rules and merge them with the automatically generated validation rules: + +```php +```php +#[MergeValidationRules] +class SongData extends Data +{ + public function __construct( + public string $title, + public string $artist, + ) { + } + + public static function rules(): array + { + return [ + 'title' => ['max:20'], + 'artist' => ['max:20'], + ]; + } +} + + +``` +#### New property mappers: + +We now ship by default a `Uppercase` and `Lowercase` mapper for mapping property names. + +### All changes: + +* Fix GitHub action fail by @rust17 in https://github.com/spatie/laravel-data/pull/918 +* Point to the right problem on ArgumentCountError exception by @nsvetozarevic in https://github.com/spatie/laravel-data/pull/884 +* Fix an issue where anonymous classes in castables were serialized (#903) by @rubenvanassche in https://github.com/spatie/laravel-data/pull/923 +* Add the ability to optionally merge automatically inferred rules with manual rules by @CWAscend in https://github.com/spatie/laravel-data/pull/848 +* Implement enum json serialization by @dont-know-php in https://github.com/spatie/laravel-data/pull/896 +* Use comments instead of docblocks in configuration file. by @edwinvdpol in https://github.com/spatie/laravel-data/pull/904 +* Casting DateTimeInterface: Truncate nanoseconds to microseconds (first 6 digits) / with Tests by @yob-yob in https://github.com/spatie/laravel-data/pull/908 +* Use container to call `Data::authorize()` to allow for dependencies by @cosmastech in https://github.com/spatie/laravel-data/pull/910 +* Improve type for CreationContextFactory::alwaysValidate by @sanfair in https://github.com/spatie/laravel-data/pull/925 +* Removed comma character from Data Rule stub by @andrey-helldar in https://github.com/spatie/laravel-data/pull/926 +* Use BaseData contract i/o Data concrete in CollectionAnnotation by @riesjart in https://github.com/spatie/laravel-data/pull/928 +* New mappers added: `LowerCaseMapper` and `UpperCaseMapper` by @andrey-helldar in https://github.com/spatie/laravel-data/pull/927 +* Allow disabling default Optional values in CreationContext by @ragulka in https://github.com/spatie/laravel-data/pull/931 +* Fix introduction.md by @pikant in https://github.com/spatie/laravel-data/pull/937 +* General code health improvements by @xHeaven in https://github.com/spatie/laravel-data/pull/920 +* Filling properties from current user by @c-v-c-v in https://github.com/spatie/laravel-data/pull/879 + +**Full Changelog**: https://github.com/spatie/laravel-data/compare/4.11.1...4.12.0 + ## 4.11.1 - 2024-10-23 - Fix an issue where the cache structures command did not work if the directory did not exist (#892) diff --git a/composer.json b/composer.json index c641c0c5c..b3d341711 100644 --- a/composer.json +++ b/composer.json @@ -26,10 +26,10 @@ "fakerphp/faker": "^1.14", "friendsofphp/php-cs-fixer": "^3.0", "inertiajs/inertia-laravel": "^1.2", + "larastan/larastan": "^2.7", "livewire/livewire": "^3.0", "mockery/mockery": "^1.6", "nesbot/carbon": "^2.63", - "nunomaduro/larastan": "^2.0", "orchestra/testbench": "^8.0|^9.0", "pestphp/pest": "^2.31", "pestphp/pest-plugin-laravel": "^2.0", diff --git a/config/data.php b/config/data.php index 3be386d1b..62bf21091 100644 --- a/config/data.php +++ b/config/data.php @@ -1,21 +1,21 @@ DATE_ATOM, - /** + /* * When transforming or casting dates, the following timezone will be used to * convert the date to the correct timezone. If set to null no timezone will * be passed. */ 'date_timezone' => null, - /** + /* * It is possible to enable certain features of the package, these would otherwise * be breaking changes, and thus they are disabled by default. In the next major * version of the package, these features will be enabled by default. @@ -23,7 +23,7 @@ 'features' => [ 'cast_and_transform_iterables' => false, - /** + /* * When trying to set a computed property value, the package will throw an exception. * You can disable this behaviour by setting this option to true, which will then just * ignore the value being passed into the computed property and recalculate it. @@ -31,7 +31,7 @@ 'ignore_exception_when_trying_to_set_computed_property_value' => false, ], - /** + /* * Global transformers will take complex types and transform them into simple * types. */ @@ -41,7 +41,7 @@ BackedEnum::class => Spatie\LaravelData\Transformers\EnumTransformer::class, ], - /** + /* * Global casts will cast values into complex types when creating a data * object from simple types. */ @@ -51,7 +51,7 @@ // Enumerable::class => Spatie\LaravelData\Casts\EnumerableCast::class, ], - /** + /* * Rule inferrers can be configured here. They will automatically add * validation rules to properties of a data object based upon * the type of the property. @@ -64,7 +64,7 @@ Spatie\LaravelData\RuleInferrers\AttributesRuleInferrer::class, ], - /** + /* * Normalizers return an array representation of the payload, or null if * it cannot normalize the payload. The normalizers below are used for * every data object, unless overridden in a specific data object class. @@ -78,14 +78,14 @@ Spatie\LaravelData\Normalizers\JsonNormalizer::class, ], - /** + /* * Data objects can be wrapped into a key like 'data' when used as a resource, * this key can be set globally here for all data objects. You can pass in * `null` if you want to disable wrapping. */ 'wrap' => null, - /** + /* * Adds a specific caster to the Symphony VarDumper component which hides * some properties from data objects and collections when being dumped * by `dump` or `dd`. Can be 'enabled', 'disabled' or 'development' @@ -93,7 +93,7 @@ */ 'var_dumper_caster_mode' => 'development', - /** + /* * It is possible to skip the PHP reflection analysis of data objects * when running in production. This will speed up the package. You * can configure where data objects are stored and which cache @@ -119,14 +119,14 @@ ], ], - /** + /* * A data object can be validated when created using a factory or when calling the from * method. By default, only when a request is passed the data is being validated. This * behaviour can be changed to always validate or to completely disable validation. */ 'validation_strategy' => \Spatie\LaravelData\Support\Creation\ValidationStrategy::OnlyRequests->value, - /** + /* * A data object can map the names of its properties when transforming (output) or when * creating (input). By default, the package will not map any names. You can set a * global strategy here, or override it on a specific data object. @@ -136,13 +136,13 @@ 'output' => null, ], - /** + /* * When using an invalid include, exclude, only or except partial, the package will * throw an exception. You can disable this behaviour by setting this option to true. */ 'ignore_invalid_partials' => false, - /** + /* * When transforming a nested chain of data objects, the package can end up in an infinite * loop when including a recursive relationship. The max transformation depth can be * set as a safety measure to prevent this from happening. When set to null, the @@ -150,32 +150,34 @@ */ 'max_transformation_depth' => null, - /** + /* * When the maximum transformation depth is reached, the package will throw an exception. * You can disable this behaviour by setting this option to true which will return an * empty array. */ 'throw_when_max_transformation_depth_reached' => true, - /** - * When using the `make:data` command, the package will use these settings to generate - * the data classes. You can override these settings by passing options to the command. - */ + /* + * When using the `make:data` command, the package will use these settings to generate + * the data classes. You can override these settings by passing options to the command. + */ 'commands' => [ - /** + + /* * Provides default configuration for the `make:data` command. These settings can be overridden with options * passed directly to the `make:data` command for generating single Data classes, or if not set they will * automatically fall back to these defaults. See `php artisan make:data --help` for more information */ 'make' => [ - /** + + /* * The default namespace for generated Data classes. This exists under the application's root namespace, * so the default 'Data` will end up as '\App\Data', and generated Data classes will be placed in the * app/Data/ folder. Data classes can live anywhere, but this is where `make:data` will put them. */ 'namespace' => 'Data', - /** + /* * This suffix will be appended to all data classes generated by make:data, so that they are less likely * to conflict with other related classes, controllers or models with a similar name without resorting * to adding an alias for the Data object. Set to a blank string (not null) to disable. @@ -184,7 +186,7 @@ ], ], - /** + /* * When using Livewire, the package allows you to enable or disable the synths * these synths will automatically handle the data objects and their * properties when used in a Livewire component. diff --git a/docs/advanced-usage/available-property-mappers.md b/docs/advanced-usage/available-property-mappers.md new file mode 100644 index 000000000..08533cc0e --- /dev/null +++ b/docs/advanced-usage/available-property-mappers.md @@ -0,0 +1,60 @@ +--- +title: Available property mappers +weight: 19 +--- + +In previous sections we've already seen how +to [create](/docs/laravel-data/v4/as-a-data-transfer-object/mapping-property-names) data objects where the keys of the +payload differ from the property names of the data object. It is also possible +to [transform](/docs/laravel-data/v4/as-a-resource/mapping-property-names) data objects to an +array/json/... where the keys of the payload differ from the property names of the data object. + +These mappings can be set manually put the package also provide a set of mappers that can be used to automatically map +property names: + +```php +class ContractData extends Data +{ + public function __construct( + #[MapName(CamelCaseMapper::class)] + public string $name, + #[MapName(SnakeCaseMapper::class)] + public string $recordCompany, + #[MapName(new ProvidedNameMapper('country field'))] + public string $country, + #[MapName(StudlyCaseMapper::class)] + public string $cityName, + #[MapName(LowerCaseMapper::class)] + public string $addressLine1, + #[MapName(UpperCaseMapper::class)] + public string $addressLine2, + ) { + } +} +``` + +Creating the data object can now be done as such: + +```php +ContractData::from([ + 'name' => 'Rick Astley', + 'record_company' => 'RCA Records', + 'country field' => 'Belgium', + 'CityName' => 'Antwerp', + 'addressline1' => 'some address line 1', + 'ADDRESSLINE2' => 'some address line 2', +]); +``` + +When transforming such a data object the payload will look like this: + +```json +{ + "name" : "Rick Astley", + "record_company" : "RCA Records", + "country field" : "Belgium", + "CityName" : "Antwerp", + "addressline1" : "some address line 1", + "ADDRESSLINE2" : "some address line 2" +} +``` diff --git a/docs/advanced-usage/filling from-route-parameters.md b/docs/advanced-usage/filling from-route-parameters.md deleted file mode 100644 index 1ef7f5fcd..000000000 --- a/docs/advanced-usage/filling from-route-parameters.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -title: Filling properties from route parameters -weight: 9 ---- - -When creating data objects from requests, it's possible to automatically fill data properties from request route parameters, such as route models. - -## Filling properties from a route parameter - -The `FromRouteParameter` attribute allows filling properties with route parameter values. - -### Using scalar route parameters - -```php -Route::patch('/songs/{songId}', [SongController::class, 'update']); - -class SongData extends Data { - #[FromRouteParameter('songId')] - public int $id; - public string $name; -} -``` - -Here, the `$id` property will be filled with the `songId` route parameter value (which most likely is a string or integer). - -### Using Models, objects or arrays as route parameters - -Given that we have a route to create songs for a specific author, and that the `{author}` route parameter uses route model binding to automatically bind to an `Author` model: - -```php -Route::post('/songs/{artist}', [SongController::class, 'store']); - -class SongData extends Data { - public int $id; - #[FromRouteParameter('artist')] - public ArtistData $author; -} -``` -Here, the `$artist` property will be filled with the `artist` route parameter value, which will be an instance of the `Artist` model. Note that the package will automatically cast the model to `ArtistData`. - -## Filling properties from route parameter properties - -The `FromRouteParameterProperty` attribute allows filling properties with values from route parameter properties. The main difference from `FromRouteParameter` is that the former uses the full route parameter value, while `FromRouteParameterProperty` uses a single property from the route parameter. - -In the example below, we're using route model binding. `{song}` represents an instance of the `Song` model. `FromRouteParameterProperty` automatically attempts to fill the `SongData` `$id` property from `$song->id`. - -```php -Route::patch('/songs/{song}', [SongController::class, 'update']); - -class SongData extends Data { - #[FromRouteParameterProperty('song')] - public int $id; - public string $name; -} -``` -### Using custom property mapping - -In the example below, `$name` property will be filled with `$song->title` (instead of `$song->name). - -```php -Route::patch('/songs/{song}', [SongController::class, 'update']); - -class SongData extends Data { - #[FromRouteParameterProperty('song')] - public int $id; - #[FromRouteParameterProperty('song', 'title')] - public string $name; -} -``` - -### Nested property mapping - -Nested properties are supported as well. Here, we fill `$singerName` from `$artist->leadSinger->name`: - -```php -Route::patch('/artists/{artist}/songs/{song}', [SongController::class, 'update']); - -class SongData extends Data { - #[FromRouteParameterProperty('song')] - public int $id; - #[FromRouteParameterProperty('artist', 'leadSinger.name')] - public string $singerName; -} -``` - -## Route parameters take priority over request body - -By default, route parameters take priority over values in the request body. For example, when the song ID is present in the route model as well as request body, the ID from route model is used. - -```php -Route::patch('/songs/{song}', [SongController::class, 'update']); - -// PATCH /songs/123 -// { "id": 321, "name": "Never gonna give you up" } - -class SongData extends Data { - #[FromRouteParameterProperty('song')] - public int $id; - public string $name; -} -``` - -Here, `$id` will be `123` even though the request body has `321` as the ID value. - -In most cases, this is useful - especially when you need the ID for a validation rule. However, there may be cases when the exact opposite is required. - -The above behavior can be turned off by switching the `replaceWhenPresentInBody` flag off. This can be useful when you _intend_ to allow updating a property that is present in a route parameter, such as a slug: - -```php -Route::patch('/songs/{slug}', [SongController::class, 'update']); - -// PATCH /songs/never -// { "slug": "never-gonna-give-you-up", "name": "Never gonna give you up" } - -class SongData extends Data { - #[FromRouteParameter('slug', replaceWhenPresentInBody: false )] - public string $slug; -} -``` - -Here, `$slug` will be `never-gonna-give-you-up` even though the route parameter value is `never`. diff --git a/docs/advanced-usage/use-with-inertia.md b/docs/advanced-usage/use-with-inertia.md index 62ca8a126..8fc77f645 100644 --- a/docs/advanced-usage/use-with-inertia.md +++ b/docs/advanced-usage/use-with-inertia.md @@ -52,3 +52,38 @@ router.reload((url, { only: ['title'], }); ``` + +### Auto lazy Inertia properties + +We already saw earlier that the package can automatically make properties Lazy, the same can be done for Inertia properties. + +It is possible to rewrite the previous example as follows: + +```php +use Spatie\LaravelData\Attributes\AutoClosureLazy;use Spatie\LaravelData\Attributes\AutoInertiaLazy; + +class SongData extends Data +{ + public function __construct( + #[AutoInertiaLazy] + public Lazy|string $title, + #[AutoClosureLazy] + public Lazy|string $artist, + ) { + } +} +``` + +If all the properties of a class should be either Inertia or closure lazy, you can use the attributes on the class level: + +```php +#[AutoInertiaLazy] +class SongData extends Data +{ + public function __construct( + public Lazy|string $title, + public Lazy|string $artist, + ) { + } +} +``` diff --git a/docs/as-a-data-transfer-object/factories.md b/docs/as-a-data-transfer-object/factories.md index 9d10772ac..8009aa0bb 100644 --- a/docs/as-a-data-transfer-object/factories.md +++ b/docs/as-a-data-transfer-object/factories.md @@ -1,6 +1,6 @@ --- title: Factories -weight: 11 +weight: 12 --- It is possible to automatically create data objects in all sorts of forms with this package. Sometimes a little bit more @@ -60,6 +60,48 @@ 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 +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` +``` + +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: diff --git a/docs/as-a-data-transfer-object/injecting-property-values.md b/docs/as-a-data-transfer-object/injecting-property-values.md new file mode 100644 index 000000000..2df40296e --- /dev/null +++ b/docs/as-a-data-transfer-object/injecting-property-values.md @@ -0,0 +1,232 @@ +--- +title: Injecting property values +weight: 11 +--- + +When creating a data object, it is possible to inject values into properties from all kinds of sources like route +parameters, the current user or dependencies in the container. + +## Filling properties from a route parameter + +When creating data objects from requests, it's possible to automatically fill data properties from request route +parameters, such as route models. + +The `FromRouteParameter` attribute allows filling properties with route parameter values. + +### Using scalar route parameters + +```php +Route::patch('/songs/{songId}', [SongController::class, 'update']); + +class SongData extends Data { + #[FromRouteParameter('songId')] + public int $id; + public string $name; +} +``` + +Here, the `$id` property will be filled with the `songId` route parameter value (which most likely is a string or +integer). + +### Using Models, objects or arrays as route parameters + +Given that we have a route to create songs for a specific author, and that the `{author}` route parameter uses route +model binding to automatically bind to an `Author` model: + +```php +Route::post('/songs/{artist}', [SongController::class, 'store']); + +class SongData extends Data { + public int $id; + #[FromRouteParameter('artist')] + public ArtistData $author; +} +``` + +Here, the `$artist` property will be filled with the `artist` route parameter value, which will be an instance of the +`Artist` model. Note that the package will automatically cast the model to `ArtistData`. + +## Filling properties from route parameter properties + +The `FromRouteParameterProperty` attribute allows filling properties with values from route parameter properties. The +main difference from `FromRouteParameter` is that the former uses the full route parameter value, while +`FromRouteParameterProperty` uses a single property from the route parameter. + +In the example below, we're using route model binding. `{song}` represents an instance of the `Song` model. +`FromRouteParameterProperty` automatically attempts to fill the `SongData` `$id` property from `$song->id`. + +```php +Route::patch('/songs/{song}', [SongController::class, 'update']); + +class SongData extends Data { + #[FromRouteParameterProperty('song')] + public int $id; + public string $name; +} +``` + +### Using custom property mapping + +In the example below, `$name` property will be filled with `$song->title` (instead of `$song->name). + +```php +Route::patch('/songs/{song}', [SongController::class, 'update']); + +class SongData extends Data { + #[FromRouteParameterProperty('song')] + public int $id; + #[FromRouteParameterProperty('song', 'title')] + public string $name; +} +``` + +### Nested property mapping + +Nested properties are supported as well. Here, we fill `$singerName` from `$artist->leadSinger->name`: + +```php +Route::patch('/artists/{artist}/songs/{song}', [SongController::class, 'update']); + +class SongData extends Data { + #[FromRouteParameterProperty('song')] + public int $id; + #[FromRouteParameterProperty('artist', 'leadSinger.name')] + public string $singerName; +} +``` + +## Route parameters take priority over request body + +By default, route parameters take priority over values in the request body. For example, when the song ID is present in +the route model as well as request body, the ID from route model is used. + +```php +Route::patch('/songs/{song}', [SongController::class, 'update']); + +// PATCH /songs/123 +// { "id": 321, "name": "Never gonna give you up" } + +class SongData extends Data { + #[FromRouteParameterProperty('song')] + public int $id; + public string $name; +} +``` + +Here, `$id` will be `123` even though the request body has `321` as the ID value. + +In most cases, this is useful - especially when you need the ID for a validation rule. However, there may be cases when +the exact opposite is required. + +The above behavior can be turned off by switching the `replaceWhenPresentInPayload` flag off. This can be useful when +you _intend_ to allow updating a property that is present in a route parameter, such as a slug: + +```php +Route::patch('/songs/{slug}', [SongController::class, 'update']); + +// PATCH /songs/never +// { "slug": "never-gonna-give-you-up", "name": "Never gonna give you up" } + +class SongData extends Data { + #[FromRouteParameter('slug', replaceWhenPresentInPayload: false )] + public string $slug; +} +``` + +Here, `$slug` will be `never-gonna-give-you-up` even though the route parameter value is `never`. + +## Filling properties from the authenticated user + +The `FromCurrentUser` attribute allows filling properties with values from the authenticated user. + +```php +class SongData extends Data { + #[FromAuthenticatedUser] + public UserData $user; +} +``` + +It is possible to specify the guard to use when fetching the user: + +```php +class SongData extends Data { + #[FromAuthenticatedUser('api')] + public UserData $user; +} +``` + +Just like with route parameters, it is possible to fill properties with specific user properties using +`FromAuthenticatedUserProperty`: + +```php +class SongData extends Data { + #[FromAuthenticatedUserProperty('name')] + public string $username; +} +``` + +All the other features like custom property mapping and not replacing values when present in the payload are supported +as well. + +## Filling properties from the container + +The `FromContainer` attribute allows filling properties with dependencies from the container. + +```php +class SongData extends Data { + #[FromContainer(SongService::class)] + public SongService $song_service; +} +``` + +When a dependency requires additional parameters these can be provided as such: + +```php +class SongData extends Data { + #[FromContainer(SongService::class, parameters: ['year' => 1984])] + public SongService $song_service; +} +``` + +It is even possible to completely inject the container itself: + +```php +class SongData extends Data { + #[FromContainer] + public Container $container; +} +``` + +Selecting a property from a dependency can be done using `FromContainerProperty`: + +```php +class SongData extends Data { + #[FromContainerProperty(SongService::class, 'name')] + public string $service_name; +} +``` + +Again, all the other features like custom property mapping and not replacing values when present in the payload are +supported as well. + +## Creating your own injectable attributes + +All the attributes we saw earlier implement the `InjectsPropertyValue` interface: + +```php +interface InjectsPropertyValue +{ + public function resolve( + DataProperty $dataProperty, + mixed $payload, + array $properties, + CreationContext $creationContext + ): mixed; + + public function shouldBeReplacedWhenPresentInPayload() : bool; +} +``` + +It is possible to create your own attribute by implementing this interface. The `resolve` method is responsible for +returning the value that should be injected into the property. The `shouldBeReplacedWhenPresentInPayload` method should +return `true` if the value should be replaced when present in the payload. 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 ac72a2df4..26efcce0d 100644 --- a/docs/as-a-data-transfer-object/mapping-property-names.md +++ b/docs/as-a-data-transfer-object/mapping-property-names.md @@ -91,3 +91,4 @@ 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). diff --git a/docs/as-a-data-transfer-object/optional-properties.md b/docs/as-a-data-transfer-object/optional-properties.md index 94f10af8b..021789471 100644 --- a/docs/as-a-data-transfer-object/optional-properties.md +++ b/docs/as-a-data-transfer-object/optional-properties.md @@ -51,3 +51,21 @@ class SongData extends Data } } ``` + +It is possible to automatically update `Optional` values to `null`: + +```php +class SongData extends Data { + public function __construct( + public string $title, + public Optional|null|string $artist, + ) { + } +} + +SongData::factory() + ->withoutOptionalValues() + ->from(['title' => 'Never gonna give you up']); // artist will `null` instead of `Optional` +``` + +You can read more about this [here](/docs/laravel-data/v4/as-a-data-transfer-object/factories#disabling-optional-values). diff --git a/docs/as-a-resource/lazy-properties.md b/docs/as-a-resource/lazy-properties.md index bf5087ea1..e02416b22 100644 --- a/docs/as-a-resource/lazy-properties.md +++ b/docs/as-a-resource/lazy-properties.md @@ -19,7 +19,8 @@ class AlbumData extends Data } ``` -This will always output a collection of songs, which can become quite large. With lazy properties, we can include properties when we want to: +This will always output a collection of songs, which can become quite large. With lazy properties, we can include +properties when we want to: ```php class AlbumData extends Data @@ -43,7 +44,8 @@ class AlbumData extends Data } ``` -The `songs` key won't be included in the resource when transforming it from a model. Because the closure that provides the data won't be called when transforming the data object unless we explicitly demand it. +The `songs` key won't be included in the resource when transforming it from a model. Because the closure that provides +the data won't be called when transforming the data object unless we explicitly demand it. Now when we transform the data object as such: @@ -69,7 +71,8 @@ AlbumData::from(Album::first())->include('songs'); 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: +It is also possible to nest these includes. For example, let's update the `SongData` class and make all of its +properties lazy: ```php class SongData extends Data @@ -108,7 +111,8 @@ If you want to include all the properties of a data object, you can do the follo AlbumData::from(Album::first())->include('songs.*'); ``` -Explicitly including properties of data objects also works on a single data object. For example, our `UserData` looks like this: +Explicitly including properties of data objects also works on a single data object. For example, our `UserData` looks +like this: ```php class UserData extends Data @@ -147,13 +151,15 @@ Lazy::create(fn() => SongData::collect($album->songs)); With a basic `Lazy` property, you must explicitly include it when the data object is transformed. -Sometimes you only want to include a property when a specific condition is true. This can be done with conditional lazy properties: +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::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. +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. ### Relational Lazy properties @@ -173,15 +179,138 @@ It is possible to mark a lazy property as included by default: 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: +The property will now always be included when the data object is transformed. You can explicitly exclude properties that +were default included as such: ```php AlbumData::create(Album::first())->exclude('songs'); ``` +## Auto Lazy + +Writing Lazy properties can be a bit cumbersome. It is often a repetitive task to write the same code over and over +again while the package can infer almost everything. + +Let's take a look at our previous example: + +```php +class UserData extends Data +{ + public function __construct( + public string $title, + public Lazy|SongData $favorite_song, + ) { + } + + public static function fromModel(User $user): self + { + return new self( + $user->title, + Lazy::create(fn() => SongData::from($user->favorite_song)) + ); + } +} +``` + +The package knows how to get the property from the model and wrap it into a data object, but since we're using a lazy +property, we need to write our own magic creation method with a lot of repetitive code. + +In such a situation auto lazy might be a good fit, instead of casting the property directly into the data object, the +casting process is wrapped in a lazy Closure. + +This makes it possible to rewrite the example as such: + +```php +#[AutoLazy] +class UserData extends Data +{ + public function __construct( + public string $title, + public Lazy|SongData $favorite_song, + ) { + } +} +``` + +While achieving the same result! + +Auto Lazy wraps the casting process of a value for every property typed as `Lazy` into a Lazy Closure when the +`AutoLazy` attribute is present on the class. + +It is also possible to use the `AutoLazy` attribute on a property level: + +```php +class UserData extends Data +{ + public function __construct( + public string $title, + #[AutoLazy] + public Lazy|SongData $favorite_song, + ) { + } +} +``` + +The auto lazy process won't be applied in the following situations: + +- When a null value is passed to the property +- When the property value isn't present in the input payload and the property typed as `Optional` +- When a Lazy Closure is passed to the property + +### Auto lazy with model relations + +When you're constructing a data object from an Eloquent model, it is also possible to automatically create lazy +properties for model relations which are only resolved when the relation is loaded: + +```php +class UserData extends Data +{ + public function __construct( + public string $title, + #[AutoWhenLoadedLazy] + public Lazy|SongData $favoriteSong, + ) { + } +} +``` + +When the `favoriteSong` relation is loaded on the model, the property will be included in the data object. + +If the name of the relation doesn't match the property name, you can specify the relation name: + +```php +class UserData extends Data +{ + public function __construct( + public string $title, + #[AutoWhenLoadedLazy('favoriteSong')] + public Lazy|SongData $favorite_song, + ) { + } +} +``` + +The package will use the regular casting process when the relation is loaded, so it is also perfectly possible to create a collection of data objects: + +```php +class UserData extends Data +{ + /** + * @param Lazy|array $favoriteSongs + */ + public function __construct( + public string $title, + #[AutoWhenLoadedLazy] + public Lazy|array $favoriteSongs, + ) { + } +} +``` + ## Only and Except -Lazy properties are great for reducing payloads sent over the wire. However, when you completely want to remove a property Laravel's `only` and `except` methods can be used: +Lazy properties are great for reducing payloads sent over the wire. However, when you completely want to remove a +property Laravel's `only` and `except` methods can be used: ```php AlbumData::from(Album::first())->only('songs'); // will only show `songs` @@ -202,7 +331,8 @@ AlbumData::from(Album::first())->only('songs.{name, artist}'); AlbumData::from(Album::first())->except('songs.{name, artist}'); ``` -Only and except always take precedence over include and exclude, which means that when a property is hidden by `only` or `except` it is impossible to show it again using `include`. +Only and except always take precedence over include and exclude, which means that when a property is hidden by `only` or +`except` it is impossible to show it again using `include`. ### Conditionally @@ -306,7 +436,7 @@ Our JSON would look like this when we request `https://spatie.be/my-account`: ```json { - "name": "Ruben Van Assche" + "name" : "Ruben Van Assche" } ``` @@ -318,8 +448,8 @@ https://spatie.be/my-account?include=favorite_song ```json { - "name": "Ruben Van Assche", - "favorite_song": { + "name" : "Ruben Van Assche", + "favorite_song" : { "name" : "Never Gonna Give You Up", "artist" : "Rick Astley" } @@ -395,7 +525,8 @@ AlbumData::from(Album::first())->include('songs')->toArray(); // will include so 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: +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 diff --git a/docs/as-a-resource/mapping-property-names.md b/docs/as-a-resource/mapping-property-names.md index 770107b6e..c21faff84 100644 --- a/docs/as-a-resource/mapping-property-names.md +++ b/docs/as-a-resource/mapping-property-names.md @@ -80,3 +80,5 @@ And a transformed version of the data object will look like this: 'record_company' => 'RCA Records', ] ``` + +The package has a set of default mappers available, you can find them [here](/docs/laravel-data/v4/advanced-usage/available-property-mappers). diff --git a/docs/installation-setup.md b/docs/installation-setup.md index fb8260146..b87d7a4ab 100644 --- a/docs/installation-setup.md +++ b/docs/installation-setup.md @@ -19,22 +19,24 @@ This is the contents of the published config file: ```php + DATE_ATOM, - /** + /* * When transforming or casting dates, the following timezone will be used to * convert the date to the correct timezone. If set to null no timezone will * be passed. */ 'date_timezone' => null, - /** + /* * It is possible to enable certain features of the package, these would otherwise * be breaking changes, and thus they are disabled by default. In the next major * version of the package, these features will be enabled by default. @@ -42,7 +44,7 @@ return [ 'features' => [ 'cast_and_transform_iterables' => false, - /** + /* * When trying to set a computed property value, the package will throw an exception. * You can disable this behaviour by setting this option to true, which will then just * ignore the value being passed into the computed property and recalculate it. @@ -50,7 +52,7 @@ return [ 'ignore_exception_when_trying_to_set_computed_property_value' => false, ], - /** + /* * Global transformers will take complex types and transform them into simple * types. */ @@ -60,7 +62,7 @@ return [ BackedEnum::class => Spatie\LaravelData\Transformers\EnumTransformer::class, ], - /** + /* * Global casts will cast values into complex types when creating a data * object from simple types. */ @@ -70,7 +72,7 @@ return [ // Enumerable::class => Spatie\LaravelData\Casts\EnumerableCast::class, ], - /** + /* * Rule inferrers can be configured here. They will automatically add * validation rules to properties of a data object based upon * the type of the property. @@ -83,7 +85,7 @@ return [ Spatie\LaravelData\RuleInferrers\AttributesRuleInferrer::class, ], - /** + /* * Normalizers return an array representation of the payload, or null if * it cannot normalize the payload. The normalizers below are used for * every data object, unless overridden in a specific data object class. @@ -97,14 +99,14 @@ return [ Spatie\LaravelData\Normalizers\JsonNormalizer::class, ], - /** + /* * Data objects can be wrapped into a key like 'data' when used as a resource, * this key can be set globally here for all data objects. You can pass in * `null` if you want to disable wrapping. */ 'wrap' => null, - /** + /* * Adds a specific caster to the Symphony VarDumper component which hides * some properties from data objects and collections when being dumped * by `dump` or `dd`. Can be 'enabled', 'disabled' or 'development' @@ -112,7 +114,7 @@ return [ */ 'var_dumper_caster_mode' => 'development', - /** + /* * It is possible to skip the PHP reflection analysis of data objects * when running in production. This will speed up the package. You * can configure where data objects are stored and which cache @@ -138,14 +140,14 @@ return [ ], ], - /** + /* * A data object can be validated when created using a factory or when calling the from * method. By default, only when a request is passed the data is being validated. This * behaviour can be changed to always validate or to completely disable validation. */ 'validation_strategy' => \Spatie\LaravelData\Support\Creation\ValidationStrategy::OnlyRequests->value, - /** + /* * A data object can map the names of its properties when transforming (output) or when * creating (input). By default, the package will not map any names. You can set a * global strategy here, or override it on a specific data object. @@ -155,13 +157,13 @@ return [ 'output' => null, ], - /** + /* * When using an invalid include, exclude, only or except partial, the package will * throw an exception. You can disable this behaviour by setting this option to true. */ 'ignore_invalid_partials' => false, - /** + /* * When transforming a nested chain of data objects, the package can end up in an infinite * loop when including a recursive relationship. The max transformation depth can be * set as a safety measure to prevent this from happening. When set to null, the @@ -169,32 +171,34 @@ return [ */ 'max_transformation_depth' => null, - /** + /* * When the maximum transformation depth is reached, the package will throw an exception. * You can disable this behaviour by setting this option to true which will return an * empty array. */ 'throw_when_max_transformation_depth_reached' => true, - /** - * When using the `make:data` command, the package will use these settings to generate - * the data classes. You can override these settings by passing options to the command. - */ + /* + * When using the `make:data` command, the package will use these settings to generate + * the data classes. You can override these settings by passing options to the command. + */ 'commands' => [ - /** + + /* * Provides default configuration for the `make:data` command. These settings can be overridden with options * passed directly to the `make:data` command for generating single Data classes, or if not set they will * automatically fall back to these defaults. See `php artisan make:data --help` for more information */ 'make' => [ - /** + + /* * The default namespace for generated Data classes. This exists under the application's root namespace, * so the default 'Data` will end up as '\App\Data', and generated Data classes will be placed in the * app/Data/ folder. Data classes can live anywhere, but this is where `make:data` will put them. */ 'namespace' => 'Data', - /** + /* * This suffix will be appended to all data classes generated by make:data, so that they are less likely * to conflict with other related classes, controllers or models with a similar name without resorting * to adding an alias for the Data object. Set to a blank string (not null) to disable. @@ -203,7 +207,7 @@ return [ ], ], - /** + /* * When using Livewire, the package allows you to enable or disable the synths * these synths will automatically handle the data objects and their * properties when used in a Livewire component. diff --git a/docs/validation/introduction.md b/docs/validation/introduction.md index 30542aa13..df02f569d 100644 --- a/docs/validation/introduction.md +++ b/docs/validation/introduction.md @@ -284,7 +284,7 @@ When mapping property names, the validation rules will be generated for the mapp class SongData extends Data { public function __construct( - #[MapFrom('song_title')] + #[MapInputName('song_title')] public string $title, ) { } diff --git a/docs/validation/manual-rules.md b/docs/validation/manual-rules.md index 79bf33225..004bf5687 100644 --- a/docs/validation/manual-rules.md +++ b/docs/validation/manual-rules.md @@ -58,6 +58,36 @@ As a rule of thumb always follow these rules: > Always use the array syntax for defining rules and not a single string which spits the rules by | characters. > This is needed when using regexes those | can be seen as part of the regex + +## Merging manual rules + +Writing manual rules doesn't mean that you can't use the automatic rules inferring anymore. By adding the `MergeValidationRules` attribute to your data class, the rules will be merged: + +```php +#[MergeValidationRules] +class SongData extends Data +{ + public function __construct( + public string $title, + public string $artist, + ) { + } + + public static function rules(): array + { + return [ + 'title' => ['max:20'], + 'artist' => ['max:20'], + ]; + } +} + +// The generated rules will look like this +[ + 'title' => [required, 'string', 'max:20'], + 'artist' => [required, 'string', 'max:20'], +] +``` ## Using attributes diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c83f5b75e..84a11e2a8 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -120,6 +120,11 @@ parameters: count: 1 path: src/Support/DataConfig.php + - + message: "#^Unsafe usage of new static\\(\\)\\.$#" + count: 1 + path: src/Support/Skipped.php + - message: "#^Call to an undefined method DateTimeInterface\\:\\:setTimezone\\(\\)\\.$#" count: 1 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5c642de1b..efc026dca 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,42 +1,26 @@ - - - - tests - - - - - ./src - - - - - - - - - - - - - + + + + tests + + + + + + + + + + + + + + + + + + ./src + + diff --git a/src/Attributes/AutoClosureLazy.php b/src/Attributes/AutoClosureLazy.php new file mode 100644 index 000000000..6793ed850 --- /dev/null +++ b/src/Attributes/AutoClosureLazy.php @@ -0,0 +1,18 @@ + $castValue($value)); + } +} diff --git a/src/Attributes/AutoInertiaLazy.php b/src/Attributes/AutoInertiaLazy.php new file mode 100644 index 000000000..280337d84 --- /dev/null +++ b/src/Attributes/AutoInertiaLazy.php @@ -0,0 +1,18 @@ + $castValue($value)); + } +} diff --git a/src/Attributes/AutoLazy.php b/src/Attributes/AutoLazy.php new file mode 100644 index 000000000..b78c504d2 --- /dev/null +++ b/src/Attributes/AutoLazy.php @@ -0,0 +1,17 @@ + $castValue($value)); + } +} diff --git a/src/Attributes/AutoWhenLoadedLazy.php b/src/Attributes/AutoWhenLoadedLazy.php new file mode 100644 index 000000000..6f381ecc0 --- /dev/null +++ b/src/Attributes/AutoWhenLoadedLazy.php @@ -0,0 +1,27 @@ +relation ?? $property->name; + + return Lazy::when(fn () => $payload->relationLoaded($relation), fn () => $castValue( + $payload->getRelation($relation) + )); + } +} diff --git a/src/Attributes/Concerns/ResolvesPropertyForInjectedValue.php b/src/Attributes/Concerns/ResolvesPropertyForInjectedValue.php new file mode 100644 index 000000000..6e17dd302 --- /dev/null +++ b/src/Attributes/Concerns/ResolvesPropertyForInjectedValue.php @@ -0,0 +1,37 @@ +getPropertyKey() ?? $dataProperty->name); + } +} diff --git a/src/Attributes/FromAuthenticatedUser.php b/src/Attributes/FromAuthenticatedUser.php new file mode 100644 index 000000000..2dc95e49f --- /dev/null +++ b/src/Attributes/FromAuthenticatedUser.php @@ -0,0 +1,39 @@ +guard)->user(); + + if ($user === null) { + return Skipped::create(); + } + + return $user; + } + + public function shouldBeReplacedWhenPresentInPayload(): bool + { + return $this->replaceWhenPresentInPayload; + } +} diff --git a/src/Attributes/FromAuthenticatedUserProperty.php b/src/Attributes/FromAuthenticatedUserProperty.php new file mode 100644 index 000000000..a3a52a9bb --- /dev/null +++ b/src/Attributes/FromAuthenticatedUserProperty.php @@ -0,0 +1,41 @@ +resolvePropertyForInjectedValue( + $dataProperty, + $payload, + $properties, + $creationContext + ); + } + + protected function getPropertyKey(): string|null + { + return $this->property; + } +} diff --git a/src/Attributes/FromContainer.php b/src/Attributes/FromContainer.php new file mode 100644 index 000000000..0bf2bfd2e --- /dev/null +++ b/src/Attributes/FromContainer.php @@ -0,0 +1,45 @@ +dependency === null + ? Container::getInstance() + : Container::getInstance()->make($this->dependency, $this->parameters); + } catch (CircularDependencyException|EntryNotFoundException|BindingResolutionException) { + return Skipped::create(); + } + + return $dependency; + } + + public function shouldBeReplacedWhenPresentInPayload(): bool + { + return $this->replaceWhenPresentInPayload; + } +} diff --git a/src/Attributes/FromContainerProperty.php b/src/Attributes/FromContainerProperty.php new file mode 100644 index 000000000..5020f924b --- /dev/null +++ b/src/Attributes/FromContainerProperty.php @@ -0,0 +1,42 @@ +resolvePropertyForInjectedValue( + $dataProperty, + $payload, + $properties, + $creationContext + ); + } + + protected function getPropertyKey(): string|null + { + return $this->property; + } +} diff --git a/src/Attributes/FromRouteParameter.php b/src/Attributes/FromRouteParameter.php index c1670bcf9..cdbfa8eb2 100644 --- a/src/Attributes/FromRouteParameter.php +++ b/src/Attributes/FromRouteParameter.php @@ -3,13 +3,43 @@ namespace Spatie\LaravelData\Attributes; use Attribute; +use Illuminate\Http\Request; +use Spatie\LaravelData\Support\Creation\CreationContext; +use Spatie\LaravelData\Support\DataProperty; +use Spatie\LaravelData\Support\Skipped; #[Attribute(Attribute::TARGET_PROPERTY)] -class FromRouteParameter +class FromRouteParameter implements InjectsPropertyValue { public function __construct( public string $routeParameter, - public bool $replaceWhenPresentInBody = true, + public bool $replaceWhenPresentInPayload = true, + /** @deprecated */ + public bool $replaceWhenPresentInBody = true ) { } + + public function resolve( + DataProperty $dataProperty, + mixed $payload, + array $properties, + CreationContext $creationContext + ): mixed { + if (! $payload instanceof Request) { + return Skipped::create(); + } + + $parameter = $payload->route($this->routeParameter); + + if ($parameter === null) { + return Skipped::create(); + } + + return $parameter; + } + + public function shouldBeReplacedWhenPresentInPayload(): bool + { + return $this->replaceWhenPresentInPayload && $this->replaceWhenPresentInBody; + } } diff --git a/src/Attributes/FromRouteParameterProperty.php b/src/Attributes/FromRouteParameterProperty.php index bd6735040..92e1f0a31 100644 --- a/src/Attributes/FromRouteParameterProperty.php +++ b/src/Attributes/FromRouteParameterProperty.php @@ -3,14 +3,41 @@ namespace Spatie\LaravelData\Attributes; use Attribute; +use Spatie\LaravelData\Attributes\Concerns\ResolvesPropertyForInjectedValue; +use Spatie\LaravelData\Support\Creation\CreationContext; +use Spatie\LaravelData\Support\DataProperty; #[Attribute(Attribute::TARGET_PROPERTY)] -class FromRouteParameterProperty +class FromRouteParameterProperty extends FromRouteParameter { + use ResolvesPropertyForInjectedValue; + public function __construct( - public string $routeParameter, + string $routeParameter, public ?string $property = null, - public bool $replaceWhenPresentInBody = true, + bool $replaceWhenPresentInPayload = true, + /** @deprecated */ + bool $replaceWhenPresentInBody = true ) { + parent::__construct($routeParameter, $replaceWhenPresentInPayload, $replaceWhenPresentInBody); + } + + public function resolve( + DataProperty $dataProperty, + mixed $payload, + array $properties, + CreationContext $creationContext + ): mixed { + return $this->resolvePropertyForInjectedValue( + $dataProperty, + $payload, + $properties, + $creationContext + ); + } + + protected function getPropertyKey(): string|null + { + return $this->property; } } diff --git a/src/Attributes/InjectsPropertyValue.php b/src/Attributes/InjectsPropertyValue.php new file mode 100644 index 000000000..901a57f5e --- /dev/null +++ b/src/Attributes/InjectsPropertyValue.php @@ -0,0 +1,18 @@ +rule; + return $this->rule ?? self::keyword(); } public static function keyword(): string diff --git a/src/Attributes/Validation/ValidationAttribute.php b/src/Attributes/Validation/ValidationAttribute.php index cbc3e110b..4c4e8eef2 100644 --- a/src/Attributes/Validation/ValidationAttribute.php +++ b/src/Attributes/Validation/ValidationAttribute.php @@ -46,11 +46,11 @@ protected static function parseBooleanValue(mixed $value): mixed } if ($value === 'true' || $value === '1') { - return true; + return 'true'; } if ($value === 'false' || $value === '0') { - return true; + return 'false'; } return $value; diff --git a/src/Attributes/WithCastable.php b/src/Attributes/WithCastable.php index b82c5c81e..b3fdc66c5 100644 --- a/src/Attributes/WithCastable.php +++ b/src/Attributes/WithCastable.php @@ -5,6 +5,7 @@ use Attribute; use Spatie\LaravelData\Casts\Cast; use Spatie\LaravelData\Casts\Castable; +use Spatie\LaravelData\Casts\CastableCast; use Spatie\LaravelData\Exceptions\CannotCreateCastAttribute; #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)] @@ -26,6 +27,9 @@ public function __construct( public function get(): Cast { - return $this->castableClass::dataCastUsing(...$this->arguments); + return new CastableCast( + $this->castableClass, + $this->arguments + ); } } diff --git a/src/Casts/CastableCast.php b/src/Casts/CastableCast.php new file mode 100644 index 000000000..e28ae7427 --- /dev/null +++ b/src/Casts/CastableCast.php @@ -0,0 +1,43 @@ + $castableClass + */ + public function __construct( + public string $castableClass, + public array $arguments + ) { + } + + public function cast(DataProperty $property, mixed $value, array $properties, CreationContext $context): mixed + { + if (! isset($this->cast)) { + $this->cast = $this->castableClass::dataCastUsing(...$this->arguments); + } + + return $this->cast->cast($property, $value, $properties, $context); + } + + public function __serialize(): array + { + return [ + 'castableClass' => $this->castableClass, + 'arguments' => $this->arguments, + ]; + } + + public function __unserialize(array $data): void + { + $this->castableClass = $data['castableClass']; + $this->arguments = $data['arguments']; + } +} diff --git a/src/Casts/DateTimeInterfaceCast.php b/src/Casts/DateTimeInterfaceCast.php index ac71df9ea..3aa75ce57 100644 --- a/src/Casts/DateTimeInterfaceCast.php +++ b/src/Casts/DateTimeInterfaceCast.php @@ -38,6 +38,11 @@ protected function castValue( return Uncastable::create(); } + // Truncate nanoseconds to microseconds (first 6 digits) + if (is_string($value)) { + $value = preg_replace('/\.(\d{6})\d*Z$/', '.$1Z', $value); + } + /** @var DateTimeInterface|null $datetime */ $datetime = $formats ->map(fn (string $format) => rescue(fn () => $type::createFromFormat( diff --git a/src/Casts/UnserializeCast.php b/src/Casts/UnserializeCast.php index db3ffc311..d06686bb4 100644 --- a/src/Casts/UnserializeCast.php +++ b/src/Casts/UnserializeCast.php @@ -8,7 +8,7 @@ class UnserializeCast implements Cast { public function __construct( - private bool $failSilently = false, + private readonly bool $failSilently = false, ) { } diff --git a/src/Commands/DataMakeCommand.php b/src/Commands/DataMakeCommand.php index 075c51fac..a828dc9b0 100644 --- a/src/Commands/DataMakeCommand.php +++ b/src/Commands/DataMakeCommand.php @@ -39,7 +39,7 @@ protected function qualifyClass($name): string { $suffix = trim($this->option('suffix')); if (! empty($suffix) && ! Str::endsWith($name, $suffix)) { - $name = $name . $suffix; + $name .= $suffix; } return parent::qualifyClass($name); diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index ba342e648..ec667826f 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -15,7 +15,7 @@ use Spatie\LaravelData\DataPipes\AuthorizedDataPipe; use Spatie\LaravelData\DataPipes\CastPropertiesDataPipe; use Spatie\LaravelData\DataPipes\DefaultValuesDataPipe; -use Spatie\LaravelData\DataPipes\FillRouteParameterPropertiesDataPipe; +use Spatie\LaravelData\DataPipes\InjectPropertyValuesPipe; use Spatie\LaravelData\DataPipes\MapPropertiesDataPipe; use Spatie\LaravelData\DataPipes\ValidatePropertiesDataPipe; use Spatie\LaravelData\PaginatedDataCollection; @@ -71,7 +71,7 @@ public static function pipeline(): DataPipeline ->into(static::class) ->through(AuthorizedDataPipe::class) ->through(MapPropertiesDataPipe::class) - ->through(FillRouteParameterPropertiesDataPipe::class) + ->through(InjectPropertyValuesPipe::class) ->through(ValidatePropertiesDataPipe::class) ->through(DefaultValuesDataPipe::class) ->through(CastPropertiesDataPipe::class); diff --git a/src/Concerns/ValidateableData.php b/src/Concerns/ValidateableData.php index b48716e4f..d4ad96ff7 100644 --- a/src/Concerns/ValidateableData.php +++ b/src/Concerns/ValidateableData.php @@ -41,7 +41,6 @@ public static function validateAndCreate(Arrayable|array $payload): static public static function withValidator(Validator $validator): void { - return; } public static function getValidationRules(array $payload): array diff --git a/src/DataPipeline.php b/src/DataPipeline.php index 912697bad..366893bd4 100644 --- a/src/DataPipeline.php +++ b/src/DataPipeline.php @@ -54,6 +54,28 @@ public function firstThrough(string|DataPipe $pipe): static return $this; } + public function endThrough(string|DataPipe $pipe): static + { + return $this->through($pipe); + } + + public function replace( + string|DataPipe $pipe, + string|DataPipe $replacement + ): static { + $pipeClass = is_string($pipe) ? $pipe : $pipe::class; + + foreach ($this->pipes as $key => $existingPipe) { + $existingPipeClass = is_string($existingPipe) ? $existingPipe : $existingPipe::class; + + if ($existingPipeClass === $pipeClass) { + $this->pipes[$key] = $replacement; + } + } + + return $this; + } + public function resolve(): ResolvedDataPipeline { $normalizers = array_merge( diff --git a/src/DataPipes/AuthorizedDataPipe.php b/src/DataPipes/AuthorizedDataPipe.php index 14b4d0182..e1041c0eb 100644 --- a/src/DataPipes/AuthorizedDataPipe.php +++ b/src/DataPipes/AuthorizedDataPipe.php @@ -27,7 +27,7 @@ public function handle( protected function ensureRequestIsAuthorized(string $class): void { /** @psalm-suppress UndefinedMethod */ - if (method_exists($class, 'authorize') && $class::authorize() === false) { + if (method_exists($class, 'authorize') && app()->call([$class, 'authorize']) === false) { throw new AuthorizationException(); } } diff --git a/src/DataPipes/CastPropertiesDataPipe.php b/src/DataPipes/CastPropertiesDataPipe.php index b13d5ce65..2c6bb206b 100644 --- a/src/DataPipes/CastPropertiesDataPipe.php +++ b/src/DataPipes/CastPropertiesDataPipe.php @@ -40,7 +40,28 @@ public function handle( continue; } - $properties[$name] = $this->cast($dataProperty, $value, $properties, $creationContext); + if ($dataProperty->autoLazy) { + $properties[$name] = $dataProperty->autoLazy->build( + fn (mixed $value) => $this->cast( + $dataProperty, + $value, + $properties, + $creationContext + ), + $payload, + $dataProperty, + $value + ); + + continue; + } + + $properties[$name] = $this->cast( + $dataProperty, + $value, + $properties, + $creationContext + ); } return $properties; @@ -129,7 +150,7 @@ protected function cast( protected function shouldBeCasted(DataProperty $property, mixed $value): bool { - if (gettype($value) !== 'object') { + if (! is_object($value)) { return true; } diff --git a/src/DataPipes/DefaultValuesDataPipe.php b/src/DataPipes/DefaultValuesDataPipe.php index 38ab18086..827349aa2 100644 --- a/src/DataPipes/DefaultValuesDataPipe.php +++ b/src/DataPipes/DefaultValuesDataPipe.php @@ -2,6 +2,8 @@ namespace Spatie\LaravelData\DataPipes; +use Illuminate\Database\Eloquent\Model; +use Spatie\LaravelData\Attributes\AutoWhenLoadedLazy; use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Creation\CreationContext; use Spatie\LaravelData\Support\DataClass; @@ -25,16 +27,23 @@ public function handle( continue; } - if ($property->type->isOptional) { + if ($property->type->isOptional && $creationContext->useOptionalValues) { $properties[$name] = Optional::create(); continue; } + if ($property->autoLazy + && $property->autoLazy instanceof AutoWhenLoadedLazy + && $property->autoLazy->relation !== null + && $payload instanceof Model + && $payload->relationLoaded($property->autoLazy->relation) + ) { + $properties[$name] = $payload->getRelation($property->autoLazy->relation); + } + if ($property->type->isNullable) { $properties[$name] = null; - - continue; } } diff --git a/src/DataPipes/FillRouteParameterPropertiesDataPipe.php b/src/DataPipes/FillRouteParameterPropertiesDataPipe.php index acc5a5bbc..29bb7070a 100644 --- a/src/DataPipes/FillRouteParameterPropertiesDataPipe.php +++ b/src/DataPipes/FillRouteParameterPropertiesDataPipe.php @@ -4,12 +4,13 @@ use Illuminate\Http\Request; use Spatie\LaravelData\Attributes\FromRouteParameter; -use Spatie\LaravelData\Attributes\FromRouteParameterProperty; -use Spatie\LaravelData\Exceptions\CannotFillFromRouteParameterPropertyUsingScalarValue; use Spatie\LaravelData\Support\Creation\CreationContext; use Spatie\LaravelData\Support\DataClass; -use Spatie\LaravelData\Support\DataProperty; +use Spatie\LaravelData\Support\Skipped; +/** + * @deprecated Use InjectPropertyValuesPipe instead + */ class FillRouteParameterPropertiesDataPipe implements DataPipe { public function handle( @@ -23,8 +24,9 @@ public function handle( } foreach ($class->properties as $dataProperty) { + /** @var FromRouteParameter|null $attribute */ $attribute = $dataProperty->attributes->first( - fn (object $attribute) => $attribute instanceof FromRouteParameter || $attribute instanceof FromRouteParameterProperty + fn (object $attribute) => $attribute instanceof FromRouteParameter ); if ($attribute === null) { @@ -33,17 +35,18 @@ public function handle( // if inputMappedName exists, use it first $name = $dataProperty->inputMappedName ?: $dataProperty->name; - if (! $attribute->replaceWhenPresentInBody && array_key_exists($name, $properties)) { + + if (! $attribute->shouldBeReplacedWhenPresentInPayload() && array_key_exists($name, $properties)) { continue; } - $parameter = $payload->route($attribute->routeParameter); + $value = $attribute->resolve($dataProperty, $payload, $properties, $creationContext); - if ($parameter === null) { + if ($value === Skipped::create()) { continue; } - $properties[$name] = $this->resolveValue($dataProperty, $attribute, $parameter); + $properties[$name] = $value; // keep the original property name if ($name !== $dataProperty->name) { @@ -53,20 +56,4 @@ public function handle( return $properties; } - - protected function resolveValue( - DataProperty $dataProperty, - FromRouteParameter|FromRouteParameterProperty $attribute, - mixed $parameter, - ): mixed { - if ($attribute instanceof FromRouteParameter) { - return $parameter; - } - - if (is_scalar($parameter)) { - throw CannotFillFromRouteParameterPropertyUsingScalarValue::create($dataProperty, $attribute, $parameter); - } - - return data_get($parameter, $attribute->property ?? $dataProperty->name); - } } diff --git a/src/DataPipes/InjectPropertyValuesPipe.php b/src/DataPipes/InjectPropertyValuesPipe.php new file mode 100644 index 000000000..4021342d3 --- /dev/null +++ b/src/DataPipes/InjectPropertyValuesPipe.php @@ -0,0 +1,47 @@ +properties as $dataProperty) { + /** @var null|InjectsPropertyValue $attribute */ + $attribute = $dataProperty->attributes->first( + fn (object $attribute) => $attribute instanceof InjectsPropertyValue + ); + + if ($attribute === null) { + continue; + } + + // if inputMappedName exists, use it first + $name = $dataProperty->inputMappedName ?: $dataProperty->name; + + if (! $attribute->shouldBeReplacedWhenPresentInPayload() && array_key_exists($name, $properties)) { + continue; + } + + $value = $attribute->resolve($dataProperty, $payload, $properties, $creationContext); + + if ($value === Skipped::create()) { + continue; + } + + $properties[$name] = $value; + + // keep the original property name + if ($name !== $dataProperty->name) { + $properties[$dataProperty->name] = $properties[$name]; + } + } + + return $properties; + } +} diff --git a/src/Enums/CustomCreationMethodType.php b/src/Enums/CustomCreationMethodType.php index 74af7544b..ceacb8f19 100644 --- a/src/Enums/CustomCreationMethodType.php +++ b/src/Enums/CustomCreationMethodType.php @@ -2,9 +2,9 @@ namespace Spatie\LaravelData\Enums; -enum CustomCreationMethodType +enum CustomCreationMethodType: string { - case None; - case Object; - case Collection; + case None = 'None'; + case Object = 'Object'; + case Collection = 'Collection'; } diff --git a/src/Enums/DataCollectableType.php b/src/Enums/DataCollectableType.php index 6e25bc99f..d1415e9e9 100644 --- a/src/Enums/DataCollectableType.php +++ b/src/Enums/DataCollectableType.php @@ -2,11 +2,11 @@ namespace Spatie\LaravelData\Enums; -enum DataCollectableType +enum DataCollectableType: string { - case Default; - case Array; - case Collection; - case Paginated; - case CursorPaginated; + case Default = 'Default'; + case Array = 'Array'; + case Collection = 'Collection'; + case Paginated = 'Paginated'; + case CursorPaginated = 'CursorPaginated'; } diff --git a/src/Enums/DataTypeKind.php b/src/Enums/DataTypeKind.php index 0c1e3c442..cb5f072fc 100644 --- a/src/Enums/DataTypeKind.php +++ b/src/Enums/DataTypeKind.php @@ -4,21 +4,21 @@ use Exception; -enum DataTypeKind +enum DataTypeKind: string { - case Default; - case Array; - case Enumerable; - case Paginator; - case CursorPaginator; - case DataObject; - case DataCollection; - case DataPaginatedCollection; - case DataCursorPaginatedCollection; - case DataArray; - case DataEnumerable; - case DataPaginator; - case DataCursorPaginator; + case Default = 'Default'; + case Array = 'Array'; + case Enumerable = 'Enumerable'; + case Paginator = 'Paginator'; + case CursorPaginator = 'CursorPaginator'; + case DataObject = 'DataObject'; + case DataCollection = 'DataCollection'; + case DataPaginatedCollection = 'DataPaginatedCollection'; + case DataCursorPaginatedCollection = 'DataCursorPaginatedCollection'; + case DataArray = 'DataArray'; + case DataEnumerable = 'DataEnumerable'; + case DataPaginator = 'DataPaginator'; + case DataCursorPaginator = 'DataCursorPaginator'; public function isDataObject(): bool { diff --git a/src/Exceptions/CannotFillFromRouteParameterPropertyUsingScalarValue.php b/src/Exceptions/CannotFillFromRouteParameterPropertyUsingScalarValue.php index 228028ae1..e5cd9a7e5 100644 --- a/src/Exceptions/CannotFillFromRouteParameterPropertyUsingScalarValue.php +++ b/src/Exceptions/CannotFillFromRouteParameterPropertyUsingScalarValue.php @@ -3,13 +3,16 @@ namespace Spatie\LaravelData\Exceptions; use Exception; -use Spatie\LaravelData\Attributes\FromRouteParameterProperty; +use Illuminate\Support\Str; +use Spatie\LaravelData\Attributes\InjectsPropertyValue; use Spatie\LaravelData\Support\DataProperty; class CannotFillFromRouteParameterPropertyUsingScalarValue extends Exception { - public static function create(DataProperty $property, FromRouteParameterProperty $attribute, mixed $value): self + public static function create(DataProperty $property, InjectsPropertyValue $attribute): self { - return new self("Attribute FromRouteParameterProperty cannot be used with scalar route parameters. {$property->className}::{$property->name} is configured to be filled from {$attribute->routeParameter}::{$attribute->property}, but the route parameter has a scalar value ({$value})."); + $attribute = Str::afterLast($attribute::class, '\\'); + + return new self("Attribute {$attribute} cannot be used with injected scalar parameters for property {$property->className}::{$property->name}"); } } diff --git a/src/Exceptions/CannotPerformPartialOnDataField.php b/src/Exceptions/CannotPerformPartialOnDataField.php index 15e478691..c06326d5e 100644 --- a/src/Exceptions/CannotPerformPartialOnDataField.php +++ b/src/Exceptions/CannotPerformPartialOnDataField.php @@ -19,7 +19,7 @@ public static function create( ): self { $message = "Tried to {$partialType->getVerb()} a non existing field `{$field}` on `{$dataClass->name}`.".PHP_EOL; $message .= 'Provided transformation context:'.PHP_EOL.PHP_EOL; - $message .= (string) $transformationContext; + $message .= $transformationContext; return new self(message: $message, previous: $exception); } diff --git a/src/Mappers/LowerCaseMapper.php b/src/Mappers/LowerCaseMapper.php new file mode 100644 index 000000000..e5aeb088f --- /dev/null +++ b/src/Mappers/LowerCaseMapper.php @@ -0,0 +1,13 @@ +name(...$parameters); } catch (ArgumentCountError $error) { - throw CannotCreateData::constructorMissingParameters( - $dataClass, - $parameters, - $error - ); + if ($this->isAnyParameterMissing($dataClass, array_keys($parameters))) { + throw CannotCreateData::constructorMissingParameters( + $dataClass, + $parameters, + $error + ); + } else { + throw $error; + } } } + + protected function isAnyParameterMissing(DataClass $dataClass, array $parameters): bool + { + return $dataClass + ->constructorMethod + ->parameters + ->filter(fn (DataParameter|DataProperty $parameter) => ! $parameter->hasDefaultValue) + ->pluck('name') + ->diff($parameters) + ->isNotEmpty(); + } } diff --git a/src/Resolvers/DataValidationMessagesAndAttributesResolver.php b/src/Resolvers/DataValidationMessagesAndAttributesResolver.php index dac90ba14..69d8919f9 100644 --- a/src/Resolvers/DataValidationMessagesAndAttributesResolver.php +++ b/src/Resolvers/DataValidationMessagesAndAttributesResolver.php @@ -45,8 +45,9 @@ public function execute( [...$nestingChain, $dataProperty->type->dataClass], ); - $messages = array_merge($messages, $nested['messages']); - $attributes = array_merge($attributes, $nested['attributes']); + + $messages[] = $nested['messages']; + $attributes[] = $nested['attributes']; continue; } @@ -63,13 +64,14 @@ public function execute( [...$nestingChain, $dataProperty->type->dataClass], ); - $messages = array_merge($messages, $collected['messages']); - $attributes = array_merge($attributes, $collected['attributes']); - - continue; + $messages[] = $collected['messages']; + $attributes[] = $collected['attributes']; } } + $messages = array_merge(...$messages); + $attributes = array_merge(...$attributes); + if (method_exists($class, 'messages')) { $messages = collect(app()->call([$class, 'messages'])) ->keyBy( diff --git a/src/Resolvers/DataValidationRulesResolver.php b/src/Resolvers/DataValidationRulesResolver.php index 3b5935d08..4cb37defe 100644 --- a/src/Resolvers/DataValidationRulesResolver.php +++ b/src/Resolvers/DataValidationRulesResolver.php @@ -5,6 +5,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Spatie\LaravelData\Attributes\MergeValidationRules; use Spatie\LaravelData\Attributes\Validation\ArrayType; use Spatie\LaravelData\Attributes\Validation\Present; use Spatie\LaravelData\Contracts\PropertyMorphableData; @@ -253,19 +254,23 @@ protected function resolveOverwrittenRules( ); $overwrittenRules = app()->call([$class->name, 'rules'], ['context' => $validationContext]); + $shouldMergeRules = $class->attributes->contains( + fn (object $attribute) => $attribute::class === MergeValidationRules::class + ); foreach ($overwrittenRules as $key => $rules) { if (in_array($key, $withoutValidationProperties)) { continue; } - $dataRules->add( - $path->property($key), - collect(Arr::wrap($rules)) - ->map(fn (mixed $rule) => $this->ruleDenormalizer->execute($rule, $path)) - ->flatten() - ->all() - ); + $rules = collect(Arr::wrap($rules)) + ->map(fn (mixed $rule) => $this->ruleDenormalizer->execute($rule, $path)) + ->flatten() + ->all(); + + $shouldMergeRules + ? $dataRules->merge($path->property($key), $rules) + : $dataRules->add($path->property($key), $rules); } } diff --git a/src/Resolvers/EmptyDataResolver.php b/src/Resolvers/EmptyDataResolver.php index 3509d2467..0749f13be 100644 --- a/src/Resolvers/EmptyDataResolver.php +++ b/src/Resolvers/EmptyDataResolver.php @@ -34,7 +34,7 @@ public function execute(string $class, array $extra = []): array return $payload; } - protected function getValueForProperty(DataProperty $property): mixed + protected function getValueForProperty(DataProperty $property): ?array { $propertyType = $property->type; diff --git a/src/Resolvers/RequestQueryStringPartialsResolver.php b/src/Resolvers/RequestQueryStringPartialsResolver.php index ead50f690..1ff34e4b7 100644 --- a/src/Resolvers/RequestQueryStringPartialsResolver.php +++ b/src/Resolvers/RequestQueryStringPartialsResolver.php @@ -151,10 +151,6 @@ protected function findField( $outputMappedProperties = $dataClass->outputMappedProperties->resolve(); - if (array_key_exists($field, $outputMappedProperties)) { - return $outputMappedProperties[$field]; - } - - return null; + return $outputMappedProperties[$field] ?? null; } } diff --git a/src/Resource.php b/src/Resource.php index 2b034c75d..4b4b51ea3 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -21,6 +21,7 @@ use Spatie\LaravelData\DataPipes\CastPropertiesDataPipe; use Spatie\LaravelData\DataPipes\DefaultValuesDataPipe; use Spatie\LaravelData\DataPipes\FillRouteParameterPropertiesDataPipe; +use Spatie\LaravelData\DataPipes\InjectPropertyValuesPipe; use Spatie\LaravelData\DataPipes\MapPropertiesDataPipe; class Resource implements BaseDataContract, AppendableDataContract, IncludeableDataContract, TransformableDataContract, ResponsableDataContract, WrappableDataContract, EmptyDataContract, ContextableDataContract @@ -40,6 +41,7 @@ public static function pipeline(): DataPipeline ->into(static::class) ->through(MapPropertiesDataPipe::class) ->through(FillRouteParameterPropertiesDataPipe::class) + ->through(InjectPropertyValuesPipe::class) ->through(DefaultValuesDataPipe::class) ->through(CastPropertiesDataPipe::class); } diff --git a/src/Support/Annotations/CollectionAnnotationReader.php b/src/Support/Annotations/CollectionAnnotationReader.php index 835000481..e30029c36 100644 --- a/src/Support/Annotations/CollectionAnnotationReader.php +++ b/src/Support/Annotations/CollectionAnnotationReader.php @@ -9,7 +9,7 @@ use phpDocumentor\Reflection\TypeResolver; use phpDocumentor\Reflection\Types\Context; use ReflectionClass; -use Spatie\LaravelData\Data; +use Spatie\LaravelData\Contracts\BaseData; use Spatie\LaravelData\Resolvers\ContextResolver; class CollectionAnnotationReader @@ -52,7 +52,7 @@ public function getForClass(string $className): ?CollectionAnnotation return self::$cache[$className] = new CollectionAnnotation( type: $type['valueType'], - isData: is_subclass_of($type['valueType'], Data::class), + isData: class_exists($type['valueType']) && in_array(BaseData::class, class_implements($type['valueType'])), keyType: $type['keyType'] ?? 'array-key', ); } diff --git a/src/Support/Caching/DataStructureCache.php b/src/Support/Caching/DataStructureCache.php index 0968cdc1f..88a5e948f 100644 --- a/src/Support/Caching/DataStructureCache.php +++ b/src/Support/Caching/DataStructureCache.php @@ -27,9 +27,7 @@ public function getConfig(): ?CachedDataConfig /** @var ?CachedDataConfig $cachedConfig */ $cachedConfig = $this->get('config'); - if ($cachedConfig) { - $cachedConfig->setCache($this); - } + $cachedConfig?->setCache($this); return $cachedConfig; } diff --git a/src/Support/Creation/CreationContext.php b/src/Support/Creation/CreationContext.php index f7de00662..374fd1875 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, ) { @@ -78,7 +79,7 @@ public function next( ): self { $this->dataClass = $dataClass; - array_push($this->currentPath, $path); + $this->currentPath[] = $path; return $this; } diff --git a/src/Support/Creation/CreationContextFactory.php b/src/Support/Creation/CreationContextFactory.php index fed7ff324..3f715e658 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, ); @@ -87,6 +90,9 @@ public function onlyValidateRequests(): self return $this; } + /** + * @return $this + */ public function alwaysValidate(): self { $this->validationStrategy = ValidationStrategy::Always; @@ -122,6 +128,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 +193,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/src/Support/DataConfig.php b/src/Support/DataConfig.php index fa2a0a30f..946bdc877 100644 --- a/src/Support/DataConfig.php +++ b/src/Support/DataConfig.php @@ -15,7 +15,7 @@ public static function createFromConfig(array $config): static $dataClasses = []; $ruleInferrers = array_map( - fn (string $ruleInferrerClass) => app($ruleInferrerClass), + app(...), $config['rule_inferrers'] ?? [] ); diff --git a/src/Support/DataProperty.php b/src/Support/DataProperty.php index 1bd86c852..5075c03f8 100644 --- a/src/Support/DataProperty.php +++ b/src/Support/DataProperty.php @@ -3,6 +3,7 @@ namespace Spatie\LaravelData\Support; use Illuminate\Support\Collection; +use Spatie\LaravelData\Attributes\AutoLazy; use Spatie\LaravelData\Casts\Cast; use Spatie\LaravelData\Transformers\Transformer; @@ -20,6 +21,7 @@ public function __construct( public readonly bool $hidden, public readonly bool $isPromoted, public readonly bool $isReadonly, + public readonly ?AutoLazy $autoLazy, public readonly bool $hasDefaultValue, public readonly mixed $defaultValue, public readonly ?Cast $cast, diff --git a/src/Support/EloquentCasts/DataEloquentCast.php b/src/Support/EloquentCasts/DataEloquentCast.php index c2af766b2..d21b4bdc4 100644 --- a/src/Support/EloquentCasts/DataEloquentCast.php +++ b/src/Support/EloquentCasts/DataEloquentCast.php @@ -74,7 +74,6 @@ public function set($model, string $key, $value, array $attributes): ?string 'data' => json_decode($value->toJson(), associative: true, flags: JSON_THROW_ON_ERROR), ]) : $value->toJson(); - ; if (in_array('encrypted', $this->arguments)) { return Crypt::encryptString($value); diff --git a/src/Support/Factories/DataClassFactory.php b/src/Support/Factories/DataClassFactory.php index 6d1659788..0c145faa3 100644 --- a/src/Support/Factories/DataClassFactory.php +++ b/src/Support/Factories/DataClassFactory.php @@ -8,6 +8,7 @@ use ReflectionMethod; use ReflectionParameter; use ReflectionProperty; +use Spatie\LaravelData\Attributes\AutoLazy; use Spatie\LaravelData\Contracts\AppendableData; use Spatie\LaravelData\Contracts\EmptyData; use Spatie\LaravelData\Contracts\IncludeableData; @@ -34,7 +35,6 @@ public function __construct( ) { } - public function build(ReflectionClass $reflectionClass): DataClass { /** @var class-string $name */ @@ -55,11 +55,16 @@ public function build(ReflectionClass $reflectionClass): DataClass ); } + $autoLazy = $attributes->first( + fn (object $attribute) => $attribute instanceof AutoLazy + ); + $properties = $this->resolveProperties( $reflectionClass, $constructorReflectionMethod, NameMappersResolver::create(ignoredMappers: [ProvidedNameMapper::class])->execute($attributes), $dataIterablePropertyAnnotations, + $autoLazy ); $responsable = $reflectionClass->implementsInterface(ResponsableData::class); @@ -138,6 +143,7 @@ protected function resolveProperties( ?ReflectionMethod $constructorReflectionMethod, array $mappers, array $dataIterablePropertyAnnotations, + ?AutoLazy $autoLazy ): Collection { $defaultValues = $this->resolveDefaultValues($reflectionClass, $constructorReflectionMethod); @@ -153,6 +159,7 @@ protected function resolveProperties( $mappers['inputNameMapper'], $mappers['outputNameMapper'], $dataIterablePropertyAnnotations[$property->getName()] ?? null, + classAutoLazy: $autoLazy ), ]); } diff --git a/src/Support/Factories/DataPropertyFactory.php b/src/Support/Factories/DataPropertyFactory.php index 1c1a519e9..4b71c86be 100644 --- a/src/Support/Factories/DataPropertyFactory.php +++ b/src/Support/Factories/DataPropertyFactory.php @@ -5,6 +5,7 @@ use ReflectionAttribute; use ReflectionClass; use ReflectionProperty; +use Spatie\LaravelData\Attributes\AutoLazy; use Spatie\LaravelData\Attributes\Computed; use Spatie\LaravelData\Attributes\GetsCast; use Spatie\LaravelData\Attributes\Hidden; @@ -32,11 +33,20 @@ public function build( ?NameMapper $classInputNameMapper = null, ?NameMapper $classOutputNameMapper = null, ?DataIterableAnnotation $classDefinedDataIterableAnnotation = null, + ?AutoLazy $classAutoLazy = null, ): DataProperty { $attributes = collect($reflectionProperty->getAttributes()) ->filter(fn (ReflectionAttribute $reflectionAttribute) => class_exists($reflectionAttribute->getName())) ->map(fn (ReflectionAttribute $reflectionAttribute) => $reflectionAttribute->newInstance()); + $type = $this->typeFactory->buildProperty( + $reflectionProperty->getType(), + $reflectionClass, + $reflectionProperty, + $attributes, + $classDefinedDataIterableAnnotation + ); + $mappers = NameMappersResolver::create()->execute($attributes); $inputMappedName = match (true) { @@ -73,21 +83,24 @@ public function build( $defaultValue = null; } + $autoLazy = $attributes->first( + fn (object $attribute) => $attribute instanceof AutoLazy + ); + + if ($classAutoLazy && $type->lazyType !== null && $autoLazy === null) { + $autoLazy = $classAutoLazy; + } + return new DataProperty( name: $reflectionProperty->name, className: $reflectionProperty->class, - type: $this->typeFactory->buildProperty( - $reflectionProperty->getType(), - $reflectionClass, - $reflectionProperty, - $attributes, - $classDefinedDataIterableAnnotation - ), + type: $type, validate: $validate, computed: $computed, hidden: $hidden, isPromoted: $reflectionProperty->isPromoted(), isReadonly: $reflectionProperty->isReadOnly(), + autoLazy: $autoLazy, hasDefaultValue: $hasDefaultValue, defaultValue: $defaultValue, cast: $attributes->first(fn (object $attribute) => $attribute instanceof GetsCast)?->get(), diff --git a/src/Support/Partials/Partial.php b/src/Support/Partials/Partial.php index 82c6c9291..18c337dff 100644 --- a/src/Support/Partials/Partial.php +++ b/src/Support/Partials/Partial.php @@ -90,7 +90,7 @@ protected static function resolveSegmentsFromPath(string $path): array substr($segmentString, 1, -1) ); - $segments[] = new FieldsPartialSegment(array_map(fn (string $field) => trim($field), $fields)); + $segments[] = new FieldsPartialSegment(array_map(trim(...), $fields)); return $segments; } diff --git a/src/Support/Skipped.php b/src/Support/Skipped.php new file mode 100644 index 000000000..98e7a902f --- /dev/null +++ b/src/Support/Skipped.php @@ -0,0 +1,17 @@ +transformers[get_debug_type($value)] ?? null; } diff --git a/src/Support/Validation/DataRules.php b/src/Support/Validation/DataRules.php index 32eb97eea..3b4c01984 100644 --- a/src/Support/Validation/DataRules.php +++ b/src/Support/Validation/DataRules.php @@ -28,6 +28,15 @@ public function add( return $this; } + public function merge( + ValidationPath $path, + array $rules + ): self { + $this->rules[$path->get()] = array_merge($this->rules[$path->get()] ?? [], $rules); + + return $this; + } + public function addCollection( ValidationPath $path, NestedRules $rules diff --git a/stubs/data-rule.stub b/stubs/data-rule.stub index 7365a05ef..155e98a5c 100644 --- a/stubs/data-rule.stub +++ b/stubs/data-rule.stub @@ -9,7 +9,7 @@ use Spatie\LaravelData\Support\Validation\ValidationContext; class DummyClass implements RuleInferrer { - public function handle(DataProperty $property, PropertyRules $rules, ValidationContext $context,): PropertyRules + public function handle(DataProperty $property, PropertyRules $rules, ValidationContext $context): PropertyRules { // } diff --git a/tests/Attributes/FromAuthenticatedUserPropertyTest.php b/tests/Attributes/FromAuthenticatedUserPropertyTest.php new file mode 100644 index 000000000..cc01c84a4 --- /dev/null +++ b/tests/Attributes/FromAuthenticatedUserPropertyTest.php @@ -0,0 +1,31 @@ +property)->toBe($user->property); +}); + +it('can get a user property value based upon a key defined in the attribute', function () { + actingAs($user = new FakeAuthenticatable()); + + $dataClass = new class () extends Data { + #[FromAuthenticatedUserProperty(property: 'property')] + public string $value; + }; + + expect($dataClass::from()->value)->toBe($user->property); +}); diff --git a/tests/Attributes/FromAuthenticatedUserTest.php b/tests/Attributes/FromAuthenticatedUserTest.php new file mode 100644 index 000000000..b89f9612b --- /dev/null +++ b/tests/Attributes/FromAuthenticatedUserTest.php @@ -0,0 +1,53 @@ +user)->toBe($user); +}); + +it('can get the current logged in user using another guard', function () { + config()->set('auth.guards.other', [ + 'driver' => 'session', + 'provider' => 'users', + ]); + + Auth::guard('other')->setUser($user = new FakeAuthenticatable()); + + $dataClass = new class () extends Data { + #[FromAuthenticatedUser] + public Authenticatable $user; + }; + + expect(isset($dataClass::from()->user))->toBeFalse(); + + $dataClass = new class () extends Data { + #[FromAuthenticatedUser(guard: 'other')] + public Authenticatable $user; + }; + + expect($dataClass::from()->user)->toBe($user); +}); + +it('will not set the user property when not logged in', function () { + $dataClass = new class () extends Data { + #[FromAuthenticatedUser] + public Authenticatable $user; + }; + + expect(isset($dataClass::from()->user))->toBeFalse(); +}); diff --git a/tests/Attributes/FromContainerPropertyTest.php b/tests/Attributes/FromContainerPropertyTest.php new file mode 100644 index 000000000..0ea34a69b --- /dev/null +++ b/tests/Attributes/FromContainerPropertyTest.php @@ -0,0 +1,45 @@ +bind('test', fn () => [ + 'property' => 'defined', + ]); + + $dataClass = new class () extends Data { + #[FromContainerProperty('test')] + public string $property; + }; + + expect($dataClass::from()->property)->toBe('defined'); +}); + +it('can get a user property value based upon a key defined in the attribute', function () { + app()->bind('test', fn () => [ + 'property' => 'defined', + ]); + + $dataClass = new class () extends Data { + #[FromContainerProperty('test', property: 'property')] + public string $value; + }; + + expect($dataClass::from()->value)->toBe('defined'); +}); + + +it('throws an exception when trying to fill a dependency property using a scalar value', function () { + app()->bind('test', fn () => 'not-valid'); + + $dataClass = new class () extends Data { + #[FromContainerProperty('test')] + public string $property; + }; + + $dataClass::from(); +})->throws(CannotFillFromRouteParameterPropertyUsingScalarValue::class); diff --git a/tests/Attributes/FromContainerTest.php b/tests/Attributes/FromContainerTest.php new file mode 100644 index 000000000..4fa2d99ea --- /dev/null +++ b/tests/Attributes/FromContainerTest.php @@ -0,0 +1,47 @@ +container)->toBe(Container::getInstance()); +}); + +it('can get a dependency from the container', function () { + app()->bind('test', fn () => 'test'); + + $dataClass = new class () extends Data { + #[FromContainer(dependency: 'test')] + public string $test; + }; + + expect($dataClass::from()->test)->toBe('test'); +}); + +it('can get a dependency from the container with parameters', function () { + app()->bind('test', fn ($app, $parameters) => $parameters['parameter']); + + $dataClass = new class () extends Data { + #[FromContainer(dependency: 'test', parameters: ['parameter' => 'Hello World'])] + public string $test; + }; + + expect($dataClass::from()->test)->toBe('Hello World'); +}); + +it('will not set a property when the dependency is not found', function () { + $dataClass = new class () extends Data { + #[FromContainer(dependency: 'test')] + public string $test; + }; + + expect(isset($dataClass::from()->test))->toBeFalse(); +}); diff --git a/tests/Attributes/FromRouteParameterPropertyTest.php b/tests/Attributes/FromRouteParameterPropertyTest.php new file mode 100644 index 000000000..a70b84330 --- /dev/null +++ b/tests/Attributes/FromRouteParameterPropertyTest.php @@ -0,0 +1,54 @@ +expects('route')->with('parameter')->once()->andReturns([ + 'property' => 'Hello World', + ]); + $requestMock->expects('toArray')->andReturns([]); + + $dataClass = new class () extends Data { + #[FromRouteParameterProperty('parameter')] + public string $property; + }; + + expect($dataClass::from($requestMock)->property)->toBe('Hello World'); +}); + +it('can get a user property value based upon a key defined in the attribute', function () { + $requestMock = mock(Request::class); + $requestMock->expects('route')->with('parameter')->once()->andReturns([ + 'test' => 'Hello World', + ]); + $requestMock->expects('toArray')->andReturns([]); + + $dataClass = new class () extends Data { + #[FromRouteParameterProperty('parameter', property: 'test')] + public string $property; + }; + + expect($dataClass::from($requestMock)->property)->toBe('Hello World'); +}); + +it('throws an exception when trying to fill a route parameter property using a scalar value', function () { + $requestMock = mock(Request::class); + $requestMock->expects('route')->with('parameter')->once()->andReturns('not-valid'); + $requestMock->expects('toArray')->andReturns([]); + + $dataClass = new class () extends Data { + #[FromRouteParameterProperty('parameter')] + public string $property; + }; + + $dataClass::from($requestMock); +})->throws(CannotFillFromRouteParameterPropertyUsingScalarValue::class); diff --git a/tests/Attributes/FromRouteParameterTest.php b/tests/Attributes/FromRouteParameterTest.php new file mode 100644 index 000000000..d8a9601b8 --- /dev/null +++ b/tests/Attributes/FromRouteParameterTest.php @@ -0,0 +1,32 @@ +expects('route')->with('parameter')->once()->andReturns('test'); + $requestMock->expects('toArray')->andReturns([]); + + expect($dataClass::from($requestMock)->property)->toBe('test'); +}); + +it('wont replace a route parameter if the payload is not a request', function () { + $dataClass = new class () extends Data { + #[FromRouteParameter('parameter')] + public string $property; + }; + + expect(isset($dataClass::from(['parameter' => 'test'])->property))->toBeFalse(); +}); diff --git a/tests/Attributes/Validation/PasswordTest.php b/tests/Attributes/Validation/PasswordTest.php index 9e05b6856..0d9253a2b 100644 --- a/tests/Attributes/Validation/PasswordTest.php +++ b/tests/Attributes/Validation/PasswordTest.php @@ -19,22 +19,22 @@ function (callable|null $setDefaults, array $expectedConfig) { } )->with(function () { yield 'min length set to 42' => [ - 'setDefaults' => fn () => ValidationPassword::defaults(fn () => ValidationPassword::min(42)), - 'expectedConfig' => [ + fn () => ValidationPassword::defaults(fn () => ValidationPassword::min(42)), + [ 'min' => 42, ], ]; // yield 'unconfigured' => [ - 'setDefaults' => fn () => ValidationPath::create(), - 'expectedConfig' => [ + fn () => ValidationPath::create(), + [ 'min' => 8, ], ]; yield 'uncompromised' => [ - 'setDefaults' => fn () => ValidationPassword::defaults(fn () => ValidationPassword::min(69)->uncompromised(7)), - 'expectedConfig' => [ + fn () => ValidationPassword::defaults(fn () => ValidationPassword::min(69)->uncompromised(7)), + [ 'min' => 69, 'uncompromised' => true, 'compromisedThreshold' => 7, diff --git a/tests/Attributes/Validation/ValidationAttributeTest.php b/tests/Attributes/Validation/ValidationAttributeTest.php index c98252f39..42bede682 100644 --- a/tests/Attributes/Validation/ValidationAttributeTest.php +++ b/tests/Attributes/Validation/ValidationAttributeTest.php @@ -36,47 +36,47 @@ public function parameters(): array expect((string) $normalizer)->toEqual("test:{$output}"); })->with(function () { yield [ - 'input' => 'Hello world', - 'output' => 'Hello world', + 'Hello world', + 'Hello world', ]; yield [ - 'input' => 42, - 'output' => '42', + 42, + '42', ]; yield [ - 'input' => 3.14, - 'output' => '3.14', + 3.14, + '3.14', ]; yield [ - 'input' => true, - 'output' => 'true', + true, + 'true', ]; yield [ - 'input' => false, - 'output' => 'false', + false, + 'false', ]; yield [ - 'input' => ['a', 'b', 'c'], - 'output' => 'a,b,c', + ['a', 'b', 'c'], + 'a,b,c', ]; yield [ - 'input' => CarbonImmutable::create(2020, 05, 16, 0, 0, 0, new DateTimeZone('Europe/Brussels')), - 'output' => '2020-05-16T00:00:00+02:00', + CarbonImmutable::create(2020, 05, 16, 0, 0, 0, new DateTimeZone('Europe/Brussels')), + '2020-05-16T00:00:00+02:00', ]; yield [ - 'input' => DummyBackedEnum::FOO, - 'output' => 'foo', + DummyBackedEnum::FOO, + 'foo', ]; yield [ - 'input' => [DummyBackedEnum::FOO, DummyBackedEnum::BOO], - 'output' => 'foo,boo', + [DummyBackedEnum::FOO, DummyBackedEnum::BOO], + 'foo,boo', ]; }); diff --git a/tests/Casts/DateTimeInterfaceCastTest.php b/tests/Casts/DateTimeInterfaceCastTest.php index 46054ec1c..5d4a0f609 100644 --- a/tests/Casts/DateTimeInterfaceCastTest.php +++ b/tests/Casts/DateTimeInterfaceCastTest.php @@ -23,7 +23,6 @@ public DateTimeImmutable $dateTimeImmutable; }; - expect( $caster->cast( FakeDataStructureFactory::property($class, 'carbon'), @@ -281,3 +280,64 @@ public function __construct( ->and($data::from(['date' => '2022-05-16 17:00:00']))->toArray() ->toMatchArray(['date' => '2022-05-16T17:00:00+00:00']); }); + + +it('can cast date times with nanosecond precision by truncating nanoseconds to microseconds', function () { + $caster = new DateTimeInterfaceCast("Y-m-d\TH:i:s.u\Z"); + + $class = new class () { + public Carbon $carbon; + + public CarbonImmutable $carbonImmutable; + + public DateTime $dateTime; + + public DateTimeImmutable $dateTimeImmutable; + }; + + + expect( + $caster->cast( + FakeDataStructureFactory::property($class, 'carbon'), + '2024-12-02T16:20:15.969827247Z', + [], + CreationContextFactory::createFromConfig($class::class)->get() + ) + )->toEqual(new Carbon('2024-12-02T16:20:15.969827247Z')); + + expect( + $caster->cast( + FakeDataStructureFactory::property($class, 'carbonImmutable'), + '2024-12-02T16:20:15.969827247Z', + [], + CreationContextFactory::createFromConfig($class::class)->get() + ) + )->toEqual(new CarbonImmutable('2024-12-02T16:20:15.969827247Z')); + + expect( + $caster->cast( + FakeDataStructureFactory::property($class, 'dateTime'), + '2024-12-02T16:20:15.969827247Z', + [], + CreationContextFactory::createFromConfig($class::class)->get() + ) + )->toEqual(new DateTime('2024-12-02T16:20:15.969827247Z')); + + expect( + $caster->cast( + FakeDataStructureFactory::property($class, 'dateTimeImmutable'), + '2024-12-02T16:20:15.969827247Z', + [], + CreationContextFactory::createFromConfig($class::class)->get() + ) + )->toEqual(new DateTimeImmutable('2024-12-02T16:20:15.969827247Z')); + + expect( + $caster->cast( + FakeDataStructureFactory::property($class, 'dateTimeImmutable'), + '2024-12-02T16:20:15.969827247Z', + [], + CreationContextFactory::createFromConfig($class::class)->get() + ) + )->toEqual(new DateTimeImmutable('2024-12-02T16:20:15.969827247Z')); +}); diff --git a/tests/CreationTest.php b/tests/CreationTest.php index 5e4398434..df616f907 100644 --- a/tests/CreationTest.php +++ b/tests/CreationTest.php @@ -10,9 +10,14 @@ use Illuminate\Support\Enumerable; use Illuminate\Support\Facades\Route; use Illuminate\Validation\ValidationException; +use Inertia\LazyProp; use function Pest\Laravel\postJson; +use Spatie\LaravelData\Attributes\AutoClosureLazy; +use Spatie\LaravelData\Attributes\AutoInertiaLazy; +use Spatie\LaravelData\Attributes\AutoLazy; +use Spatie\LaravelData\Attributes\AutoWhenLoadedLazy; use Spatie\LaravelData\Attributes\Computed; use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\Validation\Min; @@ -33,6 +38,8 @@ use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Creation\CreationContext; use Spatie\LaravelData\Support\DataClass; +use Spatie\LaravelData\Support\Lazy\ClosureLazy; +use Spatie\LaravelData\Support\Lazy\InertiaLazy; use Spatie\LaravelData\Tests\Fakes\Castables\SimpleCastable; use Spatie\LaravelData\Tests\Fakes\Casts\ConfidentialDataCast; use Spatie\LaravelData\Tests\Fakes\Casts\ConfidentialDataCollectionCast; @@ -45,10 +52,14 @@ use Spatie\LaravelData\Tests\Fakes\DataCollections\CustomCursorPaginatedDataCollection; use Spatie\LaravelData\Tests\Fakes\DataCollections\CustomDataCollection; use Spatie\LaravelData\Tests\Fakes\DataCollections\CustomPaginatedDataCollection; +use Spatie\LaravelData\Tests\Fakes\DataWithArgumentCountErrorException; use Spatie\LaravelData\Tests\Fakes\EnumData; use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnum; +use Spatie\LaravelData\Tests\Fakes\FakeNestedModelData; use Spatie\LaravelData\Tests\Fakes\ModelData; use Spatie\LaravelData\Tests\Fakes\Models\DummyModel; +use Spatie\LaravelData\Tests\Fakes\Models\FakeModel; +use Spatie\LaravelData\Tests\Fakes\Models\FakeNestedModel; use Spatie\LaravelData\Tests\Fakes\MultiData; use Spatie\LaravelData\Tests\Fakes\NestedData; use Spatie\LaravelData\Tests\Fakes\NestedLazyData; @@ -752,6 +763,18 @@ public function __construct( yield 'one param' => [['first' => 'First'], 'Could not create `Spatie\LaravelData\Tests\Fakes\MultiData`: the constructor requires 2 parameters, 1 given. Parameters given: first. Parameters missing: second.'], ]); +it('throws a readable exception message when the ArgumentCountError exception is thrown in the constructor', function () { + try { + DataWithArgumentCountErrorException::from(['string' => 'string']); + } catch (ArgumentCountError $e) { + expect($e->getMessage())->toBe('This function expects exactly 2 arguments, 1 given.'); + expect($e->getFile())->toContain('/tests/Fakes/DataWithArgumentCountErrorException.php'); + expect($e->getLine())->toBe(14); + + return; + } +}); + it('throws a readable exception message when the constructor of a nested data object fails', function () { expect(fn () => NestedData::from([ 'simple' => [], @@ -1229,6 +1252,208 @@ public static function pipeline(): DataPipeline ); })->todo(); +it('can be created without optional values', function () { + $dataClass = new class () extends Data { + public string $name; + + public string|null|Optional $description; + + public int|Optional $year = 2025; + + public string|Optional $slug; + + }; + + $data = $dataClass::factory() + ->withoutOptionalValues() + ->from([ + 'name' => 'Ruben', + ]); + + expect($data->name)->toBe('Ruben'); + expect($data->description)->toBeNull(); + expect($data->year)->toBe(2025); + expect(isset($data->slug))->toBeFalse(); + + expect($data->toArray())->toMatchArray([ + 'name' => 'Ruben', + 'description' => null, + 'year' => 2025, + ]); +}); + +it('can create a data object with auto lazy properties', function () { + $dataClass = new class () extends Data { + #[AutoLazy] + public Lazy|SimpleData $data; + + /** @var Lazy|Collection */ + #[AutoLazy] + public Lazy|Collection $dataCollection; + + #[AutoLazy] + public Lazy|string $string; + + #[AutoLazy] + public Lazy|string $overwrittenLazy; + + #[AutoLazy] + public Optional|Lazy|string $optionalLazy; + + #[AutoLazy] + public null|string|Lazy $nullableLazy; + }; + + $data = $dataClass::from([ + 'data' => 'Hello World', + 'dataCollection' => ['Hello', 'World'], + 'string' => 'Hello World', + 'overwrittenLazy' => Lazy::create(fn () => 'Overwritten Lazy'), + ]); + + expect($data->data)->toBeInstanceOf(Lazy::class); + expect($data->dataCollection)->toBeInstanceOf(Lazy::class); + expect($data->string)->toBeInstanceOf(Lazy::class); + expect($data->overwrittenLazy)->toBeInstanceOf(Lazy::class); + expect($data->optionalLazy)->toBeInstanceOf(Optional::class); + expect($data->nullableLazy)->toBeNull(); + + expect($data->toArray())->toBe([ + 'nullableLazy' => null, + ]); + + expect($data->include('data', 'dataCollection', 'string', 'overwrittenLazy')->toArray())->toBe([ + 'data' => ['string' => 'Hello World'], + 'dataCollection' => [ + ['string' => 'Hello'], + ['string' => 'World'], + ], + 'string' => 'Hello World', + 'overwrittenLazy' => 'Overwritten Lazy', + 'nullableLazy' => null, + ]); +}); + +it('can create an auto-lazy class level attribute class', function () { + #[AutoLazy] + class TestAutoLazyClassAttributeData extends Data + { + public Lazy|SimpleData $data; + + /** @var Lazy|Collection */ + public Lazy|Collection $dataCollection; + + public Lazy|string $string; + + public Lazy|string $overwrittenLazy; + + public Optional|Lazy|string $optionalLazy; + + public null|string|Lazy $nullableLazy; + + public string $regularString; + } + + $data = TestAutoLazyClassAttributeData::from([ + 'data' => 'Hello World', + 'dataCollection' => ['Hello', 'World'], + 'string' => 'Hello World', + 'overwrittenLazy' => Lazy::create(fn () => 'Overwritten Lazy'), + 'regularString' => 'Hello World', + ]); + + expect($data->data)->toBeInstanceOf(Lazy::class); + expect($data->dataCollection)->toBeInstanceOf(Lazy::class); + expect($data->string)->toBeInstanceOf(Lazy::class); + expect($data->overwrittenLazy)->toBeInstanceOf(Lazy::class); + expect($data->optionalLazy)->toBeInstanceOf(Optional::class); + expect($data->nullableLazy)->toBeNull(); + expect($data->regularString)->toBe('Hello World'); + + expect($data->toArray())->toBe([ + 'nullableLazy' => null, + 'regularString' => 'Hello World', + ]); + expect($data->include('data', 'dataCollection', 'string', 'overwrittenLazy')->toArray())->toBe([ + 'data' => ['string' => 'Hello World'], + 'dataCollection' => [ + ['string' => 'Hello'], + ['string' => 'World'], + ], + 'string' => 'Hello World', + 'overwrittenLazy' => 'Overwritten Lazy', + 'nullableLazy' => null, + 'regularString' => 'Hello World', + ]); +}); + +it('can use auto lazy to construct an inertia lazy', function () { + $dataClass = new class () extends Data { + #[AutoInertiaLazy] + public string|Lazy $string; + }; + + $data = $dataClass::from(['string' => 'Hello World']); + + expect($data->string)->toBeInstanceOf(InertiaLazy::class); + expect($data->toArray()['string'])->toBeInstanceOf(LazyProp::class); +}); + +it('can use auto lazy to construct a closure lazy', function () { + $dataClass = new class () extends Data { + #[AutoClosureLazy] + public string|Lazy $string; + }; + + $data = $dataClass::from(['string' => 'Hello World']); + + expect($data->string)->toBeInstanceOf(ClosureLazy::class); + expect($data->toArray()['string'])->toBeInstanceOf(Closure::class); +}); + + +it('can use auto lazy to construct a when loaded lazy', function () { + $dataClass = new class () extends Data { + #[AutoWhenLoadedLazy] + /** @property array */ + public array|Lazy $fakeNestedModels; + }; + + $model = FakeModel::factory() + ->has(FakeNestedModel::factory()->count(2)) + ->create(); + + expect($dataClass::from($model)->all())->toBeEmpty(); + + $model->load('fakeNestedModels'); + + expect($dataClass::from($model)->all()['fakeNestedModels']) + ->toBeArray() + ->toHaveCount(2) + ->each()->toBeInstanceOf(FakeNestedModelData::class); +}); + +it('can use auto lazy to construct a when loaded lazy with a manual defined relation', function () { + $dataClass = new class () extends Data { + #[AutoWhenLoadedLazy('fakeNestedModels')] + /** @property array */ + public array|Lazy $models; + }; + + $model = FakeModel::factory() + ->has(FakeNestedModel::factory()->count(2)) + ->create(); + + expect($dataClass::from($model)->all())->toBeEmpty(); + + $model->load('fakeNestedModels'); + + expect($dataClass::from($model)->all()['models']) + ->toBeArray() + ->toHaveCount(2) + ->each()->toBeInstanceOf(FakeNestedModelData::class); +}); + describe('property-morphable creation tests', function () { abstract class TestAbstractPropertyMorphableData extends Data implements PropertyMorphableData { diff --git a/tests/FillRouteParametersTest.php b/tests/DataPipes/FillRouteParameterPropertiesDataPipeTest.php similarity index 77% rename from tests/FillRouteParametersTest.php rename to tests/DataPipes/FillRouteParameterPropertiesDataPipeTest.php index 13986c263..e39efef67 100644 --- a/tests/FillRouteParametersTest.php +++ b/tests/DataPipes/FillRouteParameterPropertiesDataPipeTest.php @@ -1,5 +1,6 @@ replace( + InjectPropertyValuesPipe::class, + FillRouteParameterPropertiesDataPipe::class, + ); + } }; $requestMock = mock(Request::class); + $requestMock->expects('route')->with('id')->once()->andReturns(123); $requestMock->expects('route')->with('slug')->once()->andReturns('foo-bar'); $requestMock->expects('route')->with('title')->once()->andReturns('Foo Bar'); $requestMock->expects('route')->with('tags')->once()->andReturns(['foo', 'bar']); $requestMock->expects('route')->with('nested')->once()->andReturns(['simple' => ['string' => 'baz']]); + $requestMock->expects('toArray')->andReturns([]); $data = $dataClass::from($requestMock); @@ -51,6 +69,14 @@ public string $name; #[FromRouteParameterProperty('baz')] public string $description; + + public static function pipeline(): DataPipeline + { + return parent::pipeline()->replace( + InjectPropertyValuesPipe::class, + FillRouteParameterPropertiesDataPipe::class, + ); + } }; $foo = new class () extends Model { @@ -90,6 +116,14 @@ #[FromRouteParameter('user_id')] #[MapInputName('user_id')] public int $userId; + + public static function pipeline(): DataPipeline + { + return parent::pipeline()->replace( + InjectPropertyValuesPipe::class, + FillRouteParameterPropertiesDataPipe::class, + ); + } }; $something = [ @@ -129,6 +163,14 @@ #[FromRouteParameter('user_id')] #[MapInputName('user_id')] public int $userId; + + public static function pipeline(): DataPipeline + { + return parent::pipeline()->replace( + InjectPropertyValuesPipe::class, + FillRouteParameterPropertiesDataPipe::class, + ); + } }; $foo = 'Foo Lighters'; @@ -153,6 +195,14 @@ public string $name; #[FromRouteParameterProperty('bar', replaceWhenPresentInBody: false)] public string $slug; + + public static function pipeline(): DataPipeline + { + return parent::pipeline()->replace( + InjectPropertyValuesPipe::class, + FillRouteParameterPropertiesDataPipe::class, + ); + } }; $requestMock = mock(Request::class); @@ -172,6 +222,14 @@ public string $name; #[FromRouteParameterProperty('bar')] public ?string $slug = 'default-slug'; + + public static function pipeline(): DataPipeline + { + return parent::pipeline()->replace( + InjectPropertyValuesPipe::class, + FillRouteParameterPropertiesDataPipe::class, + ); + } }; $requestMock = mock(Request::class); @@ -189,6 +247,14 @@ $dataClass = new class () extends Data { #[FromRouteParameterProperty('foo', 'bar')] public string $name; + + public static function pipeline(): DataPipeline + { + return parent::pipeline()->replace( + InjectPropertyValuesPipe::class, + FillRouteParameterPropertiesDataPipe::class, + ); + } }; $requestMock = mock(Request::class); diff --git a/tests/Datasets/RulesDataset.php b/tests/Datasets/RulesDataset.php index 6672c34c7..28b396214 100644 --- a/tests/Datasets/RulesDataset.php +++ b/tests/Datasets/RulesDataset.php @@ -108,10 +108,10 @@ function fixature( string $exception = null ): array { return [ - 'attribute' => $attribute, - 'expected' => $expected, - 'expectedCreatedAttribute' => $expectCreatedAttribute ?? $attribute, - 'exception' => $exception, + $attribute, + $expected, + $expectCreatedAttribute ?? $attribute, + $exception, ]; } diff --git a/tests/Fakes/DataWithArgumentCountErrorException.php b/tests/Fakes/DataWithArgumentCountErrorException.php new file mode 100644 index 000000000..5cd8fdd0d --- /dev/null +++ b/tests/Fakes/DataWithArgumentCountErrorException.php @@ -0,0 +1,16 @@ +parameter] ?? Skipped::create(); + } + + public function shouldBeReplacedWhenPresentInPayload(): bool + { + return $this->replaceWhenPresentInBody; + } +} + +#[Attribute(Attribute::TARGET_PROPERTY)] +class TestFromInjectingParameterProperty extends TestFromInjectingParameter +{ + use ResolvesPropertyForInjectedValue; + + public function __construct( + string $parameter, + public ?string $key = null, + bool $replaceWhenPresentInBody = true + ) { + parent::__construct($parameter, $replaceWhenPresentInBody); + } + + public function resolve(DataProperty $dataProperty, mixed $payload, array $properties, CreationContext $creationContext): mixed + { + return $this->resolvePropertyForInjectedValue( + $dataProperty, + $payload, + $properties, + $creationContext + ); + } + + protected function getPropertyKey(): string|null + { + return $this->key; + } +} + +it('can fill data properties with injected parameters', function () { + TestFromInjectingParameter::$payload = [ + 'string' => 'Hello World', + 'array' => ['a', 'b'], + 'data' => new SimpleData('Hello World'), + ]; + + $dataClass = new class () extends Data { + #[TestFromInjectingParameter('string')] + public string $string; + + #[TestFromInjectingParameter('array')] + public array $array; + + #[TestFromInjectingParameter('data')] + public SimpleData $data; + }; + + $data = $dataClass::from(); + + expect($data->string)->toEqual('Hello World'); + expect($data->array)->toEqual(['a', 'b']); + expect($data->data)->toBeInstanceOf(SimpleData::class); + expect($data->data->string)->toEqual('Hello World'); +}); + +it('can fill data properties from injected parameter properties', function () { + TestFromInjectingParameterProperty::$payload = [ + 'object' => (object) ['title' => 'Rick'], + 'array' => ['description' => 'Astley'], + ]; + + $dataClass = new class () extends Data { + #[TestFromInjectingParameterProperty('object')] + public string $title; + + #[TestFromInjectingParameterProperty('array')] + public string $description; + }; + + $data = $dataClass::from(); + + expect($data->title)->toEqual('Rick'); + expect($data->description)->toEqual('Astley'); +}); + +it('can fill data properties from injected parameters using custom property mapping ', function () { + TestFromInjectingParameterProperty::$payload = TestFromInjectingParameter::$payload = [ + 'something' => [ + 'name' => 'Something', + 'nested' => [ + 'foo' => 'bar', + ], + 'tags' => ['foo', 'bar'], + 'rows' => [ + ['total' => 10], + ['total' => 20], + ['total' => 30], + ], + ], + 'user_id' => 1, + ]; + + $dataClass = new class () extends Data { + #[TestFromInjectingParameterProperty('something', 'name')] + public string $title; + + #[TestFromInjectingParameterProperty('something', 'nested.foo')] + public string $foo; + + #[TestFromInjectingParameterProperty('something', 'tags.0')] + public string $tag; + + #[TestFromInjectingParameterProperty('something', 'rows.*.total')] + public array $totals; + + #[TestFromInjectingParameter('user_id')] + #[MapInputName('user_id')] + public int $userId; + }; + + $data = $dataClass::from(); + + expect($data->title)->toEqual('Something'); + expect($data->foo)->toEqual('bar'); + expect($data->tag)->toEqual('foo'); + expect($data->totals)->toEqual([10, 20, 30]); + expect($data->userId)->toEqual(1); +}); + +it('replaces properties when injected parameter properties exist', function () { + TestFromInjectingParameterProperty::$payload = TestFromInjectingParameterProperty::$payload = [ + 'foo' => 'Rick', + 'bar' => ['slug' => 'Astley'], + 'user_id' => 2, + ]; + + $dataClass = new class () extends Data { + #[TestFromInjectingParameter('foo')] + public string $name; + + #[TestFromInjectingParameterProperty('bar')] + public string $slug; + + #[TestFromInjectingParameter('user_id')] + #[MapInputName('user_id')] + public int $userId; + }; + + $data = $dataClass::from([ + 'name' => 'Jon', + 'slug' => 'Bon Jovi', + ]); + + expect($data->name)->toEqual('Rick'); + expect($data->slug)->toEqual('Astley'); + expect($data->userId)->toEqual(2); +}); + +it('skips replacing properties when route parameter properties exist and replacing is disabled', function () { + TestFromInjectingParameter::$payload = TestFromInjectingParameterProperty::$payload = []; + + $dataClass = new class () extends Data { + #[TestFromInjectingParameter('foo', replaceWhenPresentInBody: false)] + public string $name; + + #[TestFromInjectingParameterProperty('bar', replaceWhenPresentInBody: false)] + public string $slug; + }; + + $requestMock = mock(Request::class); + $requestMock->expects('route')->with('foo')->never(); + $requestMock->expects('route')->with('bar')->never(); + $requestMock->expects('toArray')->andReturns([ + 'name' => 'Jon', + 'slug' => 'Bon Jovi', + ]); + + $data = $dataClass::from($requestMock); + + expect($data->name)->toEqual('Jon'); + expect($data->slug)->toEqual('Bon Jovi'); +}); + +it('skips properties it cannot find a route parameter for', function () { + TestFromInjectingParameter::$payload = TestFromInjectingParameterProperty::$payload = []; + + $dataClass = new class () extends Data { + #[TestFromInjectingParameter('foo')] + public string $name; + + #[TestFromInjectingParameterProperty('bar')] + public ?string $slug = 'default-slug'; + }; + + $data = $dataClass::from([ + 'name' => 'Jon Bon Jovi', + ]); + + expect($data->name)->toEqual('Jon Bon Jovi'); + expect($data->slug)->toEqual('default-slug'); +}); diff --git a/tests/MappingTest.php b/tests/MappingTest.php index 1840d963f..26d255184 100644 --- a/tests/MappingTest.php +++ b/tests/MappingTest.php @@ -6,9 +6,11 @@ use Spatie\LaravelData\Attributes\MapOutputName; use Spatie\LaravelData\Data; use Spatie\LaravelData\Mappers\CamelCaseMapper; +use Spatie\LaravelData\Mappers\LowerCaseMapper; use Spatie\LaravelData\Mappers\ProvidedNameMapper; use Spatie\LaravelData\Mappers\SnakeCaseMapper; use Spatie\LaravelData\Mappers\StudlyCaseMapper; +use Spatie\LaravelData\Mappers\UpperCaseMapper; use Spatie\LaravelData\Support\Transformation\TransformationContextFactory; use Spatie\LaravelData\Tests\Fakes\DataWithMapper; use Spatie\LaravelData\Tests\Fakes\SimpleData; @@ -328,6 +330,12 @@ public function __construct( #[MapName(StudlyCaseMapper::class)] public string $studly_case = 'StudlyCase'; + #[MapName(LowerCaseMapper::class)] + public string $lowercase = 'lowercase'; + + #[MapName(UpperCaseMapper::class)] + public string $uppercase = 'UPPERCASE'; + #[MapName(new ProvidedNameMapper('i_provided'))] public string $provided = 'provided'; }; @@ -336,6 +344,8 @@ public function __construct( 'camelCase' => 'camelCase', 'snake_case' => 'snake_case', 'StudlyCase' => 'StudlyCase', + 'lowercase' => 'lowercase', + 'UPPERCASE' => 'UPPERCASE', 'i_provided' => 'provided', ]); @@ -343,10 +353,14 @@ public function __construct( 'camelCase' => 'camelCase', 'snake_case' => 'snake_case', 'StudlyCase' => 'StudlyCase', + 'lowercase' => 'lowercase', + 'UPPERCASE' => 'UPPERCASE', 'i_provided' => 'provided', ])) ->camel_case->toBe('camelCase') ->snakeCase->toBe('snake_case') ->studly_case->toBe('StudlyCase') + ->lowercase->toBe('lowercase') + ->uppercase->toBe('UPPERCASE') ->provided->toBe('provided'); }); diff --git a/tests/PartialsTest.php b/tests/PartialsTest.php index 280274073..e6dfdd275 100644 --- a/tests/PartialsTest.php +++ b/tests/PartialsTest.php @@ -1023,45 +1023,45 @@ protected function includeProperties(): array expect($data->toArray())->toBe($expectedPartialPayload); })->with(function () { yield [ - 'data' => new LazyData( + 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'], + ), // data + fn (LazyData $data) => $data->include('name'), // temporaryPartial + fn (LazyData $data) => $data->includePermanently('name'), // permanentPartial + [], // expectedFullPayload + ['name' => 'Rick Astley'], // expectedPartialPayload ]; yield [ - 'data' => new LazyData( + 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' => [], + ), // data + fn (LazyData $data) => $data->exclude('name'), // temporaryPartial + fn (LazyData $data) => $data->excludePermanently('name'), // permanentPartial + ['name' => 'Rick Astley'], // expectedFullPayload + [], // expectedPartialPayload ]; yield [ - 'data' => new MultiData( + new MultiData( 'Rick Astley', - 'Never gonna give you up', + 'Never gonna give you up', // data ), - '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'], + fn (MultiData $data) => $data->only('first'), // temporaryPartial + fn (MultiData $data) => $data->onlyPermanently('first'), // permanentPartial + ['first' => 'Rick Astley', 'second' => 'Never gonna give you up'], // expectedFullPayload + ['first' => 'Rick Astley'], // expectedPartialPayload ]; yield [ - 'data' => new MultiData( + new MultiData( 'Rick Astley', - 'Never gonna give you up', + 'Never gonna give you up', // data ), - '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'], + fn (MultiData $data) => $data->except('first'), // temporaryPartial + fn (MultiData $data) => $data->exceptPermanently('first'), // permanentPartial + ['first' => 'Rick Astley', 'second' => 'Never gonna give you up'], // expectedFullPayload + ['second' => 'Never gonna give you up'], // expectedPartialPayload ]; }); @@ -1185,183 +1185,183 @@ public static function allowedRequestIncludes(): ?array expect($data->toResponse($request)->getData(assoc: true))->toEqual($expectedResponse); })->with(function () { yield 'disallowed property inclusion' => [ - 'lazyDataAllowedIncludes' => [], - 'dataAllowedIncludes' => [], - 'includes' => 'property', - 'expectedPartials' => PartialsCollection::create(), - 'expectedResponse' => [], + [], // lazyDataAllowedIncludes + [], // dataAllowedIncludes + 'property', // includes + PartialsCollection::create(), // expectedPartials + [], // expectedResponse ]; yield 'allowed property inclusion' => [ - 'lazyDataAllowedIncludes' => [], - 'dataAllowedIncludes' => ['property'], - 'includes' => 'property', - 'expectedPartials' => PartialsCollection::create( + [], // lazyDataAllowedIncludes + ['property'], // dataAllowedIncludes + 'property', // includes + PartialsCollection::create( Partial::create('property'), - ), - 'expectedResponse' => [ + ),// expectedPartials + [ 'property' => 'Hello', - ], + ],// expectedResponse ]; yield 'allowed data property inclusion without nesting' => [ - 'lazyDataAllowedIncludes' => [], - 'dataAllowedIncludes' => ['nested'], - 'includes' => 'nested.name', - 'expectedPartials' => PartialsCollection::create( + [], // lazyDataAllowedIncludes + ['nested'], // dataAllowedIncludes + 'nested.name', // includes + PartialsCollection::create( Partial::create('nested'), - ), - 'expectedResponse' => [ + ),// expectedPartials + [ 'nested' => [], - ], + ],// expectedResponse ]; yield 'allowed data property inclusion with nesting' => [ - 'lazyDataAllowedIncludes' => ['name'], - 'dataAllowedIncludes' => ['nested'], - 'includes' => 'nested.name', - 'expectedPartials' => PartialsCollection::create( + ['name'], // lazyDataAllowedIncludes + ['nested'], // dataAllowedIncludes + 'nested.name', // includes + PartialsCollection::create( Partial::create('nested.name'), - ), - 'expectedResponse' => [ + ),// expectedPartials + [ 'nested' => [ 'name' => 'Hello', ], - ], + ], // expectedResponse ]; yield 'allowed data collection property inclusion without nesting' => [ - 'lazyDataAllowedIncludes' => [], - 'dataAllowedIncludes' => ['collection'], - 'includes' => 'collection.name', - 'expectedPartials' => PartialsCollection::create( + [], // lazyDataAllowedIncludes + ['collection'], // dataAllowedIncludes + 'collection.name', // includes + PartialsCollection::create( Partial::create('collection'), - ), - 'expectedResponse' => [ + ), // expectedPartials + [ 'collection' => [ [], [], ], - ], + ], // expectedResponse ]; yield 'allowed data collection property inclusion with nesting' => [ - 'lazyDataAllowedIncludes' => ['name'], - 'dataAllowedIncludes' => ['collection'], - 'includes' => 'collection.name', - 'expectedPartials' => PartialsCollection::create( + ['name'], // lazyDataAllowedIncludes + ['collection'], // dataAllowedIncludes + 'collection.name', // includes + PartialsCollection::create( Partial::create('collection.name'), - ), - 'expectedResponse' => [ + ), // expectedPartials + [ 'collection' => [ ['name' => 'Hello'], ['name' => 'World'], ], - ], + ], // expectedResponse ]; yield 'allowed nested data property inclusion without defining allowed includes on nested' => [ - 'lazyDataAllowedIncludes' => null, - 'dataAllowedIncludes' => ['nested'], - 'includes' => 'nested.name', - 'expectedPartials' => PartialsCollection::create( + null, // lazyDataAllowedIncludes + ['nested'], // dataAllowedIncludes + 'nested.name', // includes + PartialsCollection::create( Partial::create('nested.name'), - ), - 'expectedResponse' => [ + ), // expectedPartials + [ 'nested' => [ 'name' => 'Hello', ], - ], + ], // expectedResponse ]; yield 'allowed all nested data property inclusion without defining allowed includes on nested' => [ - 'lazyDataAllowedIncludes' => null, - 'dataAllowedIncludes' => ['nested'], - 'includes' => 'nested.*', - 'expectedPartials' => PartialsCollection::create( + null, // lazyDataAllowedIncludes + ['nested'], // dataAllowedIncludes + 'nested.*', // includes + PartialsCollection::create( Partial::create('nested.*'), - ), - 'expectedResponse' => [ + ), // expectedPartials + [ 'nested' => [ 'name' => 'Hello', ], - ], + ], // expectedResponse ]; yield 'disallowed all nested data property inclusion ' => [ - 'lazyDataAllowedIncludes' => [], - 'dataAllowedIncludes' => ['nested'], - 'includes' => 'nested.*', - 'expectedPartials' => PartialsCollection::create( + [], // lazyDataAllowedIncludes + ['nested'], // dataAllowedIncludes + 'nested.*', // includes + PartialsCollection::create( Partial::create('nested'), - ), - 'expectedResponse' => [ + ), // expectedPartials + [ 'nested' => [], - ], + ], // expectedResponse ]; yield 'multi property inclusion' => [ - 'lazyDataAllowedIncludes' => null, - 'dataAllowedIncludes' => ['nested', 'property'], - 'includes' => 'nested.*,property', - 'expectedPartials' => PartialsCollection::create( + null, // lazyDataAllowedIncludes + ['nested', 'property'], // dataAllowedIncludes + 'nested.*,property', // includes + PartialsCollection::create( Partial::create('nested.*'), Partial::create('property'), - ), - 'expectedResponse' => [ + ), // expectedPartials + [ 'property' => 'Hello', 'nested' => [ 'name' => 'Hello', ], - ], + ], // expectedResponse ]; yield 'without property inclusion' => [ - 'lazyDataAllowedIncludes' => null, - 'dataAllowedIncludes' => ['nested', 'property'], - 'includes' => null, - 'expectedPartials' => null, - 'expectedResponse' => [], + null, // lazyDataAllowedIncludes + ['nested', 'property'], // dataAllowedIncludes + null, // includes + null, // expectedPartials + [], // expectedResponse ]; yield 'with invalid partial definition' => [ - 'lazyDataAllowedIncludes' => null, - 'dataAllowedIncludes' => null, - 'includes' => '', - 'expectedPartials' => PartialsCollection::create(), - 'expectedResponse' => [], + null, // lazyDataAllowedIncludes + null, // dataAllowedIncludes + '', // includes + PartialsCollection::create(), // expectedPartials + [], // expectedResponse ]; yield 'with non existing field' => [ - 'lazyDataAllowedIncludes' => [], - 'dataAllowedIncludes' => [], - 'includes' => 'non-existing', - 'expectedPartials' => PartialsCollection::create(), - 'expectedResponse' => [], + [], // lazyDataAllowedIncludes + [], // dataAllowedIncludes + 'non-existing', // includes + PartialsCollection::create(), // expectedPartials + [], // expectedResponse ]; yield 'with non existing nested field' => [ - 'lazyDataAllowedIncludes' => [], - 'dataAllowedIncludes' => [], - 'includes' => 'non-existing.still-non-existing', - 'expectedPartials' => PartialsCollection::create(), - 'expectedResponse' => [], + [], // lazyDataAllowedIncludes + [], // dataAllowedIncludes + 'non-existing.still-non-existing', // includes + PartialsCollection::create(), // expectedPartials + [], // expectedResponse ]; yield 'with non allowed nested field' => [ - 'lazyDataAllowedIncludes' => [], - 'dataAllowedIncludes' => [], - 'includes' => 'nested.name', - 'expectedPartials' => PartialsCollection::create(), - 'expectedResponse' => [], + [], // lazyDataAllowedIncludes + [], // dataAllowedIncludes + 'nested.name', // includes + PartialsCollection::create(), // expectedPartials + [], // expectedResponse ]; yield 'with non allowed nested all' => [ - 'lazyDataAllowedIncludes' => [], - 'dataAllowedIncludes' => [], - 'includes' => 'nested.*', - 'expectedPartials' => PartialsCollection::create(), - 'expectedResponse' => [], + [], // lazyDataAllowedIncludes + [], // dataAllowedIncludes + 'nested.*', // includes + PartialsCollection::create(), // expectedPartials + [], // expectedResponse ]; }); @@ -1406,13 +1406,13 @@ public static function allowedRequestIncludes(): ?array expect($data)->toHaveKeys($expected); })->with(function () { yield 'input as array' => [ - 'input' => ['include' => ['artist', 'name']], - 'expected' => ['artist', 'name'], + ['include' => ['artist', 'name']], // input + ['artist', 'name'], // expected ]; yield 'input as comma separated' => [ - 'input' => ['include' => 'artist,name'], - 'expected' => ['artist', 'name'], + ['include' => 'artist,name'], // input + ['artist', 'name'], // expected ]; }); diff --git a/tests/PipelineTest.php b/tests/PipelineTest.php index cd7d5c4e1..9dca95287 100644 --- a/tests/PipelineTest.php +++ b/tests/PipelineTest.php @@ -6,6 +6,7 @@ use Spatie\LaravelData\DataPipes\AuthorizedDataPipe; use Spatie\LaravelData\DataPipes\CastPropertiesDataPipe; use Spatie\LaravelData\DataPipes\DefaultValuesDataPipe; +use Spatie\LaravelData\DataPipes\MapPropertiesDataPipe; it('can prepend a data pipe at the beginning of the pipeline', function () { $pipeline = DataPipeline::create() @@ -29,6 +30,48 @@ ]); }); +it('replaces an existing pipe with a new one', function () { + $pipeline = DataPipeline::create() + ->through(DefaultValuesDataPipe::class) + ->through(CastPropertiesDataPipe::class) + ->replace(DefaultValuesDataPipe::class, AuthorizedDataPipe::class); + + $reflectionProperty = tap( + new ReflectionProperty(DataPipeline::class, 'pipes'), + static fn (ReflectionProperty $r) => $r->setAccessible(true), + ); + + $pipes = $reflectionProperty->getValue($pipeline); + + expect($pipes) + ->toHaveCount(2) + ->toMatchArray([ + AuthorizedDataPipe::class, + CastPropertiesDataPipe::class, + ]); +}); + +it('does not replace a non-existing pipe', function () { + $pipeline = DataPipeline::create() + ->through(DefaultValuesDataPipe::class) + ->through(CastPropertiesDataPipe::class) + ->replace(MapPropertiesDataPipe::class, AuthorizedDataPipe::class); + + $reflectionProperty = tap( + new ReflectionProperty(DataPipeline::class, 'pipes'), + static fn (ReflectionProperty $r) => $r->setAccessible(true), + ); + + $pipes = $reflectionProperty->getValue($pipeline); + + expect($pipes) + ->toHaveCount(2) + ->toMatchArray([ + DefaultValuesDataPipe::class, + CastPropertiesDataPipe::class, + ]); +}); + it('can restructure payload before entering the pipeline', function () { $class = new class () extends Data { public function __construct( diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 19efbfc4b..e2733ff72 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -143,3 +143,39 @@ public static function fromRequest(Request $request) ->assertJson(['name' => 'Rick Astley']); } ); + +it('can build authorize parameters from the container', function (): void { + class SomeDependency + { + public function __construct(public string $street) + { + } + } + + app()->bind(SomeDependency::class, fn () => new SomeDependency('Sesame')); + class AuthorizeFromContainerRequest extends Data + { + public static string $street; + + public function __construct(public string $name) + { + } + + public static function authorize(SomeDependency $dependency): bool + { + self::$street = $dependency->street; + + return $dependency->street === 'Sesame'; + } + } + + Route::post('/route-with-authorization-dependencies', function (\AuthorizeFromContainerRequest $data) { + return ['name' => $data->name, 'street' => \AuthorizeFromContainerRequest::$street]; + }); + + postJson('/route-with-authorization-dependencies', [ + 'name' => 'Rick Astley', + ]) + ->assertOk() + ->assertJson(['name' => 'Rick Astley', 'street' => 'Sesame']); +}); diff --git a/tests/Resolvers/VisibleDataFieldsResolverTest.php b/tests/Resolvers/VisibleDataFieldsResolverTest.php index 5f23eb8ce..302828190 100644 --- a/tests/Resolvers/VisibleDataFieldsResolverTest.php +++ b/tests/Resolvers/VisibleDataFieldsResolverTest.php @@ -342,15 +342,15 @@ public static function instance(): self expect($data->transform($factory))->toEqual($expectedTransformed); })->with(function () { yield 'single field' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->except('single'), - 'fields' => [ + [ 'string' => null, 'int' => null, 'nested' => new TransformationContext(), 'collection' => new TransformationContext(), ], - 'transformed' => [ + [ 'string' => 'hello', 'int' => 42, 'nested' => [ @@ -365,13 +365,13 @@ public static function instance(): self ]; yield 'multiple fields' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->except('{string,int,single}'), - 'fields' => [ + [ 'nested' => new TransformationContext(), 'collection' => new TransformationContext(), ], - 'transformed' => [ + [ 'nested' => [ 'a' => ['string' => 'hello', 'int' => 42], 'b' => ['string' => 'hello', 'int' => 42], @@ -384,24 +384,24 @@ public static function instance(): self ]; yield 'all' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->except('*'), - 'fields' => [], - 'transformed' => [], + [], + [], ]; yield 'nested data object single field' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->except('string', 'int', 'single', 'collection') // ignore non nested object fields ->except('nested.a'), - 'fields' => [ + [ 'nested' => new TransformationContext( exceptPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new FieldsPartialSegment(['a'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'nested' => [ 'b' => ['string' => 'hello', 'int' => 42], ], @@ -409,49 +409,49 @@ public static function instance(): self ]; yield 'nested data object multiple fields' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->except('string', 'int', 'single', 'collection') // ignore non nested object fields ->except('nested.{a,b}'), - 'fields' => [ + [ 'nested' => new TransformationContext( exceptPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new FieldsPartialSegment(['a', 'b'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'nested' => [], ], ]; yield 'nested data object all' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->except('string', 'int', 'single', 'collection') // ignore non nested object fields ->except('nested.*'), - 'fields' => [ + [ 'nested' => new TransformationContext( exceptPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new AllPartialSegment()], pointer: 1) ), ), ], - 'transformed' => [ + [ 'nested' => [], ], ]; yield 'nested data collectable single field' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->except('string', 'int', 'single', 'nested') // ignore non collection fields ->except('collection.string'), - 'fields' => [ + [ 'collection' => new TransformationContext( exceptPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('collection'), new FieldsPartialSegment(['string'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'collection' => [ ['int' => 42], ['int' => 42], @@ -460,17 +460,17 @@ public static function instance(): self ]; yield 'nested data collectable multiple fields' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->except('string', 'int', 'single', 'nested') // ignore non collection fields ->except('collection.{string,int}'), - 'fields' => [ + [ 'collection' => new TransformationContext( exceptPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('collection'), new FieldsPartialSegment(['string', 'int'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'collection' => [ [], [], @@ -479,17 +479,17 @@ public static function instance(): self ]; yield 'nested data collectable all' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->except('string', 'int', 'single', 'nested') // ignore non collection fields ->except('collection.*'), - 'fields' => [ + [ 'collection' => new TransformationContext( exceptPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('collection'), new AllPartialSegment()], pointer: 1) ), ), ], - 'transformed' => [ + [ 'collection' => [ [], [], @@ -498,11 +498,11 @@ public static function instance(): self ]; yield 'combination' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->except('string', 'int', 'single.string') ->except('collection.string') ->except('nested.a.string'), - 'fields' => [ + [ 'single' => new TransformationContext( exceptPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('single'), new FieldsPartialSegment(['string'])], pointer: 1) @@ -519,7 +519,7 @@ public static function instance(): self ), ), ], - 'transformed' => [ + [ 'single' => ['int' => 42], 'collection' => [ ['int' => 42], @@ -550,25 +550,25 @@ public static function instance(): self expect($data->transform($factory))->toEqual($expectedTransformed); })->with(function () { yield 'single field' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('single'), - 'fields' => [ + [ 'single' => new TransformationContext(), ], - 'transformed' => [ + [ 'single' => ['string' => 'hello', 'int' => 42,], ], ]; yield 'multiple fields' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('{string,int,single}'), - 'fields' => [ + [ 'string' => null, 'int' => null, 'single' => new TransformationContext(), ], - 'transformed' => [ + [ 'string' => 'hello', 'int' => 42, 'single' => ['string' => 'hello', 'int' => 42,], @@ -576,16 +576,16 @@ public static function instance(): self ]; yield 'all' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('*'), - 'fields' => [ + [ 'string' => null, 'int' => null, 'single' => new TransformationContext(), 'nested' => new TransformationContext(), 'collection' => new TransformationContext(), ], - 'transformed' => [ + [ 'string' => 'hello', 'int' => 42, 'single' => ['string' => 'hello', 'int' => 42,], @@ -601,16 +601,16 @@ public static function instance(): self ]; yield 'nested data object single field' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('nested.a'), - 'fields' => [ + [ 'nested' => new TransformationContext( onlyPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new FieldsPartialSegment(['a'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'nested' => [ 'a' => ['string' => 'hello', 'int' => 42], ], @@ -618,16 +618,16 @@ public static function instance(): self ]; yield 'nested data object multiple fields' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('nested.{a,b}'), - 'fields' => [ + [ 'nested' => new TransformationContext( onlyPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new FieldsPartialSegment(['a', 'b'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'nested' => [ 'a' => ['string' => 'hello', 'int' => 42], 'b' => ['string' => 'hello', 'int' => 42], @@ -636,16 +636,16 @@ public static function instance(): self ]; yield 'nested data object all' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('nested.*'), - 'fields' => [ + [ 'nested' => new TransformationContext( onlyPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new AllPartialSegment()], pointer: 1) ), ), ], - 'transformed' => [ + [ 'nested' => [ 'a' => ['string' => 'hello', 'int' => 42], 'b' => ['string' => 'hello', 'int' => 42], @@ -654,16 +654,16 @@ public static function instance(): self ]; yield 'nested data collectable single field' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('collection.string'), - 'fields' => [ + [ 'collection' => new TransformationContext( onlyPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('collection'), new FieldsPartialSegment(['string'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'collection' => [ ['string' => 'hello'], ['string' => 'hello'], @@ -672,16 +672,16 @@ public static function instance(): self ]; yield 'nested data collectable multiple fields' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('collection.{string,int}'), - 'fields' => [ + [ 'collection' => new TransformationContext( onlyPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('collection'), new FieldsPartialSegment(['string', 'int'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'collection' => [ ['string' => 'hello', 'int' => 42], ['string' => 'hello', 'int' => 42], @@ -690,16 +690,16 @@ public static function instance(): self ]; yield 'nested data collectable all' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('collection.*'), - 'fields' => [ + [ 'collection' => new TransformationContext( onlyPartials: PartialsCollection::create( new Partial([new NestedPartialSegment('collection'), new AllPartialSegment()], pointer: 1) ), ), ], - 'transformed' => [ + [ 'collection' => [ ['string' => 'hello', 'int' => 42], ['string' => 'hello', 'int' => 42], @@ -708,11 +708,11 @@ public static function instance(): self ]; yield 'combination' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('string', 'single.string') ->only('collection.string') ->only('nested.a.string'), - 'fields' => [ + [ 'string' => null, 'single' => new TransformationContext( onlyPartials: PartialsCollection::create( @@ -730,7 +730,7 @@ public static function instance(): self ), ), ], - 'transformed' => [ + [ 'string' => 'hello', 'single' => ['string' => 'hello'], 'collection' => [ @@ -822,25 +822,25 @@ public static function instance(bool $includeByDefault): self expect($data->transform($factory))->toEqual($expectedTransformed); })->with(function () { yield 'single field' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->include('single'), - 'fields' => [ + [ 'single' => new TransformationContext(), ], - 'transformed' => [ + [ 'single' => [], ], ]; yield 'multiple fields' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->include('{string,int,single}'), - 'fields' => [ + [ 'single' => new TransformationContext(), 'int' => null, 'string' => null, ], - 'transformed' => [ + [ 'single' => [], 'int' => 42, 'string' => 'hello', @@ -848,9 +848,9 @@ public static function instance(bool $includeByDefault): self ]; yield 'all' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->include('*'), - 'fields' => [ + [ 'single' => new TransformationContext( includePartials: PartialsCollection::create( new Partial([new AllPartialSegment()], pointer: 3) @@ -869,7 +869,7 @@ public static function instance(bool $includeByDefault): self ), ), ], - 'transformed' => [ + [ 'single' => ['string' => 'hello', 'int' => 42,], 'int' => 42, 'string' => 'hello', @@ -885,17 +885,17 @@ public static function instance(bool $includeByDefault): self ]; yield 'nested data object single field' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('nested') // ignore non nested object fields ->include('nested.a'), - 'fields' => [ + [ 'nested' => new TransformationContext( includePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new FieldsPartialSegment(['a'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'nested' => [ 'a' => [], ], @@ -903,17 +903,17 @@ public static function instance(bool $includeByDefault): self ]; yield 'nested data object multiple fields' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('nested') // ignore non nested object fields ->include('nested.{a,b}'), - 'fields' => [ + [ 'nested' => new TransformationContext( includePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new FieldsPartialSegment(['a', 'b'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'nested' => [ 'a' => [], 'b' => [], @@ -922,17 +922,17 @@ public static function instance(bool $includeByDefault): self ]; yield 'nested data object all' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('nested') // ignore non nested object fields ->include('nested.*'), - 'fields' => [ + [ 'nested' => new TransformationContext( includePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new AllPartialSegment()], pointer: 1) ), ), ], - 'transformed' => [ + [ 'nested' => [ 'a' => ['string' => 'hello', 'int' => 42], 'b' => ['string' => 'hello', 'int' => 42], @@ -941,10 +941,10 @@ public static function instance(bool $includeByDefault): self ]; yield 'nested data object deep nesting' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('nested') // ignore non nested object fields ->include('nested.a.string', 'nested.b.int'), - 'fields' => [ + [ 'nested' => new TransformationContext( includePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new NestedPartialSegment('a'), new FieldsPartialSegment(['string'])], pointer: 1), @@ -952,7 +952,7 @@ public static function instance(bool $includeByDefault): self ), ), ], - 'transformed' => [ + [ 'nested' => [ 'a' => ['string' => 'hello'], 'b' => ['int' => 42], @@ -961,17 +961,17 @@ public static function instance(bool $includeByDefault): self ]; yield 'nested data collectable single field' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('collection') // ignore non collection fields ->include('collection.string'), - 'fields' => [ + [ 'collection' => new TransformationContext( includePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('collection'), new FieldsPartialSegment(['string'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'collection' => [ ['string' => 'hello'], ['string' => 'hello'], @@ -980,17 +980,17 @@ public static function instance(bool $includeByDefault): self ]; yield 'nested data collectable multiple fields' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('collection') // ignore non collection fields ->include('collection.{string,int}'), - 'fields' => [ + [ 'collection' => new TransformationContext( includePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('collection'), new FieldsPartialSegment(['string', 'int'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'collection' => [ ['string' => 'hello', 'int' => 42], ['string' => 'hello', 'int' => 42], @@ -999,17 +999,17 @@ public static function instance(bool $includeByDefault): self ]; yield 'nested data collectable all' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('collection') // ignore non collection fields ->include('collection.*'), - 'fields' => [ + [ 'collection' => new TransformationContext( includePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('collection'), new AllPartialSegment()], pointer: 1) ), ), ], - 'transformed' => [ + [ 'collection' => [ ['string' => 'hello', 'int' => 42], ['string' => 'hello', 'int' => 42], @@ -1018,11 +1018,11 @@ public static function instance(bool $includeByDefault): self ]; yield 'combination' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->include('string', 'single.string') ->include('collection.string') ->include('nested.a.string'), - 'fields' => [ + [ 'string' => null, 'single' => new TransformationContext( includePartials: PartialsCollection::create( @@ -1040,7 +1040,7 @@ public static function instance(bool $includeByDefault): self ), ), ], - 'transformed' => [ + [ 'string' => 'hello', 'single' => ['string' => 'hello'], 'collection' => [ @@ -1071,15 +1071,15 @@ public static function instance(bool $includeByDefault): self expect($data->transform($factory))->toEqual($expectedTransformed); })->with(function () { yield 'single field' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->exclude('single'), - 'fields' => [ + [ 'string' => null, 'int' => null, 'nested' => new TransformationContext(), 'collection' => new TransformationContext(), ], - 'transformed' => [ + [ 'string' => 'hello', 'int' => 42, 'nested' => [ @@ -1094,13 +1094,13 @@ public static function instance(bool $includeByDefault): self ]; yield 'multiple fields' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->exclude('{string,int,single}'), - 'fields' => [ + [ 'nested' => new TransformationContext(), 'collection' => new TransformationContext(), ], - 'transformed' => [ + [ 'nested' => [ 'a' => ['string' => 'hello', 'int' => 42], 'b' => ['string' => 'hello', 'int' => 42], @@ -1113,24 +1113,24 @@ public static function instance(bool $includeByDefault): self ]; yield 'all' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->exclude('*'), - 'fields' => [], - 'transformed' => [], + [], + [], ]; yield 'nested data object single field' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('nested') // ignore non nested object fields ->exclude('nested.a'), - 'fields' => [ + [ 'nested' => new TransformationContext( excludePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new FieldsPartialSegment(['a'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'nested' => [ 'b' => ['string' => 'hello', 'int' => 42], ], @@ -1138,42 +1138,42 @@ public static function instance(bool $includeByDefault): self ]; yield 'nested data object multiple fields' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('nested') // ignore non nested object fields ->exclude('nested.{a,b}'), - 'fields' => [ + [ 'nested' => new TransformationContext( excludePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new FieldsPartialSegment(['a', 'b'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'nested' => [], ], ]; yield 'nested data object all' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('nested') // ignore non nested object fields ->exclude('nested.*'), - 'fields' => [ + [ 'nested' => new TransformationContext( excludePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new AllPartialSegment()], pointer: 1) ), ), ], - 'transformed' => [ + [ 'nested' => [], ], ]; yield 'nested data object deep nesting' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('nested') // ignore non nested object fields ->exclude('nested.a.string', 'nested.b.int'), - 'fields' => [ + [ 'nested' => new TransformationContext( excludePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('nested'), new NestedPartialSegment('a'), new FieldsPartialSegment(['string'])], pointer: 1), @@ -1181,7 +1181,7 @@ public static function instance(bool $includeByDefault): self ), ), ], - 'transformed' => [ + [ 'nested' => [ 'a' => ['int' => 42], 'b' => ['string' => 'hello'], @@ -1190,17 +1190,17 @@ public static function instance(bool $includeByDefault): self ]; yield 'nested data collectable single field' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('collection') // ignore non collection fields ->exclude('collection.string'), - 'fields' => [ + [ 'collection' => new TransformationContext( excludePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('collection'), new FieldsPartialSegment(['string'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'collection' => [ ['int' => 42], ['int' => 42], @@ -1209,17 +1209,17 @@ public static function instance(bool $includeByDefault): self ]; yield 'nested data collectable multiple fields' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('collection') // ignore non collection fields ->exclude('collection.{string,int}'), - 'fields' => [ + [ 'collection' => new TransformationContext( excludePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('collection'), new FieldsPartialSegment(['string', 'int'])], pointer: 1) ), ), ], - 'transformed' => [ + [ 'collection' => [ [], [], @@ -1228,17 +1228,17 @@ public static function instance(bool $includeByDefault): self ]; yield 'nested data collectable all' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->only('collection') // ignore non collection fields ->exclude('collection.*'), - 'fields' => [ + [ 'collection' => new TransformationContext( excludePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('collection'), new AllPartialSegment()], pointer: 1) ), ), ], - 'transformed' => [ + [ 'collection' => [ [], [], @@ -1247,11 +1247,11 @@ public static function instance(bool $includeByDefault): self ]; yield 'combination' => [ - 'factory' => fn () => TransformationContextFactory::create() + fn () => TransformationContextFactory::create() ->exclude('string', 'single.string') ->exclude('collection.string') ->exclude('nested.a.string'), - 'fields' => [ + [ 'single' => new TransformationContext( excludePartials: PartialsCollection::create( new Partial([new NestedPartialSegment('single'), new FieldsPartialSegment(['string'])], pointer: 1) @@ -1269,7 +1269,7 @@ public static function instance(bool $includeByDefault): self ), 'int' => null, ], - 'transformed' => [ + [ 'single' => ['int' => 42], 'collection' => [ ['int' => 42], diff --git a/tests/SerializeableTest.php b/tests/SerializeableTest.php index 7d9bc8b39..9b702e4f1 100644 --- a/tests/SerializeableTest.php +++ b/tests/SerializeableTest.php @@ -2,6 +2,7 @@ use Spatie\LaravelData\DataCollection; +use Spatie\LaravelData\Exceptions\CannotCreateData; use Spatie\LaravelData\Lazy; use Spatie\LaravelData\Support\Lazy\DefaultLazy; use Spatie\LaravelData\Tests\Fakes\LazyData; @@ -9,6 +10,10 @@ use function Spatie\Snapshots\assertMatchesSnapshot; +beforeEach(function () { + ini_set('zend.exception_ignore_args', 'Off'); +}); + it('can serialize and unserialize a data object', function () { $object = SimpleData::from('Hello world'); @@ -83,3 +88,17 @@ expect($unserialized)->toBeInstanceOf(LazyData::class); expect($unserialized->toArray())->toMatchArray(['name' => 'Hello world']); }); + +it('can json_encode exception trace with args when not all required properties passed', function () { + // When zend.exception_ignore_args is set to Off, the trace will contain the arguments + // We want to make sure these all can be encoded into JSON + + try { + SimpleData::from([]); + } catch (CannotCreateData $e) { + $trace = $e->getTrace(); + $encodedJson = json_encode($trace, JSON_THROW_ON_ERROR); + + expect($encodedJson)->toBeJson(); + } +}); diff --git a/tests/Support/Annotations/CollectionAnnotationReaderTest.php b/tests/Support/Annotations/CollectionAnnotationReaderTest.php index 287c6aa28..9d80486c2 100644 --- a/tests/Support/Annotations/CollectionAnnotationReaderTest.php +++ b/tests/Support/Annotations/CollectionAnnotationReaderTest.php @@ -21,88 +21,88 @@ function (string $className, ?CollectionAnnotation $expected) { } )->with(function () { yield DataCollectionWithTemplate::class => [ - 'className' => DataCollectionWithTemplate::class, - 'expected' => new CollectionAnnotation(type: SimpleData::class, isData: true), + DataCollectionWithTemplate::class, // className + new CollectionAnnotation(type: SimpleData::class, isData: true), // expected ]; yield DataCollectionWithoutTemplate::class => [ - 'className' => DataCollectionWithoutTemplate::class, - 'expected' => new CollectionAnnotation(type: SimpleData::class, isData: true), + DataCollectionWithoutTemplate::class, // className + new CollectionAnnotation(type: SimpleData::class, isData: true), // expected ]; yield DataCollectionWithCombinationType::class => [ - 'className' => DataCollectionWithCombinationType::class, - 'expected' => new CollectionAnnotation(type: SimpleData::class, isData: true), + DataCollectionWithCombinationType::class, // className + new CollectionAnnotation(type: SimpleData::class, isData: true), // expected ]; yield DataCollectionWithIntegerKey::class => [ - 'className' => DataCollectionWithIntegerKey::class, - 'expected' => new CollectionAnnotation(type: SimpleData::class, isData: true, keyType: 'int'), + DataCollectionWithIntegerKey::class, // className + new CollectionAnnotation(type: SimpleData::class, isData: true, keyType: 'int'), // expected ]; yield DataCollectionWithCombinationKey::class => [ - 'className' => DataCollectionWithCombinationKey::class, - 'expected' => new CollectionAnnotation(type: SimpleData::class, isData: true, keyType: 'int'), + DataCollectionWithCombinationKey::class, // className + new CollectionAnnotation(type: SimpleData::class, isData: true, keyType: 'int'), // expected ]; yield DataCollectionWithoutKey::class => [ - 'className' => DataCollectionWithoutKey::class, - 'expected' => new CollectionAnnotation(type: SimpleData::class, isData: true), + DataCollectionWithoutKey::class, // className + new CollectionAnnotation(type: SimpleData::class, isData: true), // expected ]; yield NonDataCollectionWithTemplate::class => [ - 'className' => NonDataCollectionWithTemplate::class, - 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), + NonDataCollectionWithTemplate::class, // className + new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), // expected ]; yield NonDataCollectionWithoutTemplate::class => [ - 'className' => NonDataCollectionWithoutTemplate::class, - 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), + NonDataCollectionWithoutTemplate::class, // className + new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), // expected ]; yield NonDataCollectionWithCombinationType::class => [ - 'className' => NonDataCollectionWithCombinationType::class, - 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), + NonDataCollectionWithCombinationType::class, // className + new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), // expected ]; yield NonDataCollectionWithIntegerKey::class => [ - 'className' => NonDataCollectionWithIntegerKey::class, - 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false, keyType: 'int'), + NonDataCollectionWithIntegerKey::class, // className + new CollectionAnnotation(type: DummyBackedEnum::class, isData: false, keyType: 'int'), // expected ]; yield NonDataCollectionWithCombinationKey::class => [ - 'className' => NonDataCollectionWithCombinationKey::class, - 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false, keyType: 'int'), + NonDataCollectionWithCombinationKey::class, // className + new CollectionAnnotation(type: DummyBackedEnum::class, isData: false, keyType: 'int'), // expected ]; yield NonDataCollectionWithoutKey::class => [ - 'className' => NonDataCollectionWithoutKey::class, - 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), + NonDataCollectionWithoutKey::class, // className + new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), // expected ]; yield CollectionWhoImplementsIterator::class => [ - 'className' => CollectionWhoImplementsIterator::class, - 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), + CollectionWhoImplementsIterator::class, // className + new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), // expected ]; yield CollectionWhoImplementsIteratorAggregate::class => [ - 'className' => CollectionWhoImplementsIteratorAggregate::class, - 'expected' => new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), + CollectionWhoImplementsIteratorAggregate::class, // className + new CollectionAnnotation(type: DummyBackedEnum::class, isData: false), // expected ]; yield CollectionWhoImplementsNothing::class => [ - 'className' => CollectionWhoImplementsNothing::class, - 'expected' => null, + CollectionWhoImplementsNothing::class, // className + null, // expected ]; yield CollectionWithoutDocBlock::class => [ - 'className' => CollectionWithoutDocBlock::class, - 'expected' => null, + CollectionWithoutDocBlock::class, // className + null, // expected ]; yield CollectionWithoutType::class => [ - 'className' => CollectionWithoutType::class, - 'expected' => null, + CollectionWithoutType::class, // className + null, // expected ]; }); diff --git a/tests/Support/Annotations/DataIterableAnnotationReaderTest.php b/tests/Support/Annotations/DataIterableAnnotationReaderTest.php index bf9f0d4fa..7ceec20ce 100644 --- a/tests/Support/Annotations/DataIterableAnnotationReaderTest.php +++ b/tests/Support/Annotations/DataIterableAnnotationReaderTest.php @@ -18,73 +18,73 @@ function (string $property, ?DataIterableAnnotation $expected) { } )->with(function () { yield 'propertyA' => [ - 'property' => 'propertyA', - 'expected' => new DataIterableAnnotation(SimpleData::class, isData: true), + 'propertyA', // property + new DataIterableAnnotation(SimpleData::class, isData: true), // expected ]; yield 'propertyB' => [ - 'property' => 'propertyB', - 'expected' => new DataIterableAnnotation(SimpleData::class, isData: true), + 'propertyB', // property + new DataIterableAnnotation(SimpleData::class, isData: true), // expected ]; yield 'propertyC' => [ - 'property' => 'propertyC', - 'expected' => new DataIterableAnnotation(SimpleData::class, isData: true), + 'propertyC', // property + new DataIterableAnnotation(SimpleData::class, isData: true), // expected ]; yield 'propertyD' => [ - 'property' => 'propertyD', - 'expected' => new DataIterableAnnotation(SimpleData::class, isData: true), + 'propertyD', // property + new DataIterableAnnotation(SimpleData::class, isData: true), // expected ]; yield 'propertyE' => [ - 'property' => 'propertyE', - 'expected' => new DataIterableAnnotation(SimpleData::class, isData: true), + 'propertyE', // property + new DataIterableAnnotation(SimpleData::class, isData: true), // expected ]; yield 'propertyF' => [ - 'property' => 'propertyF', - 'expected' => new DataIterableAnnotation(SimpleData::class, isData: true), + 'propertyF', // property + new DataIterableAnnotation(SimpleData::class, isData: true), // expected ]; yield 'propertyG' => [ - 'property' => 'propertyG', - 'expected' => new DataIterableAnnotation(SimpleData::class, isData: true), + 'propertyG', // property + new DataIterableAnnotation(SimpleData::class, isData: true), // expected ]; - yield 'propertyH' => [ - 'property' => 'propertyH', - 'expected' => null, // Attribute + yield 'propertyH' => [ // Attribute + 'propertyH', // property + null, // expected ]; - yield 'propertyI' => [ - 'property' => 'propertyI', - 'expected' => null, // Invalid definition + yield 'propertyI' => [ // Invalid definition + 'propertyI', // property + null, // expected ]; - yield 'propertyJ' => [ - 'property' => 'propertyJ', - 'expected' => null, // No definition + yield 'propertyJ' => [ // No definition + 'propertyJ', // property + null, // expected ]; yield 'propertyK' => [ - 'property' => 'propertyK', - 'expected' => new DataIterableAnnotation(SimpleData::class, isData: true), + 'propertyK', // property + new DataIterableAnnotation(SimpleData::class, isData: true), // expected ]; yield 'propertyL' => [ - 'property' => 'propertyL', - 'expected' => new DataIterableAnnotation(SimpleData::class, isData: true), + 'propertyL', // property + new DataIterableAnnotation(SimpleData::class, isData: true), // expected ]; yield 'propertyM' => [ - 'property' => 'propertyM', - 'expected' => new DataIterableAnnotation(SimpleData::class, isData: true), + 'propertyM', // property + new DataIterableAnnotation(SimpleData::class, isData: true), // expected ]; yield 'propertyU' => [ - 'property' => 'propertyU', - 'expected' => new DataIterableAnnotation(SimpleData::class, isData: true), + 'propertyU', // property + new DataIterableAnnotation(SimpleData::class, isData: true), // expected ]; }); @@ -129,73 +129,73 @@ function (string $property, ?DataIterableAnnotation $expected) { } )->with(function () { yield 'propertyA' => [ - 'property' => 'propertyA', - 'expected' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false), + 'propertyA', // property + new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected ]; yield 'propertyB' => [ - 'property' => 'propertyB', - 'expected' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false), + 'propertyB', // property + new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected ]; yield 'propertyC' => [ - 'property' => 'propertyC', - 'expected' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false), + 'propertyC', // property + new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected ]; yield 'propertyD' => [ - 'property' => 'propertyD', - 'expected' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false), + 'propertyD', // property + new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected ]; yield 'propertyE' => [ - 'property' => 'propertyE', - 'expected' => new DataIterableAnnotation('string', isData: false), + 'propertyE', // property + new DataIterableAnnotation('string', isData: false), // expected ]; yield 'propertyF' => [ - 'property' => 'propertyF', - 'expected' => new DataIterableAnnotation('string', isData: false), + 'propertyF', // property + new DataIterableAnnotation('string', isData: false), // expected ]; yield 'propertyG' => [ - 'property' => 'propertyG', - 'expected' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false), + 'propertyG', // property + new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected ]; - yield 'propertyH' => [ - 'property' => 'propertyH', - 'expected' => null, // Invalid + yield 'propertyH' => [ // Invalid + 'propertyH', // property + null, // expected ]; - yield 'propertyI' => [ - 'property' => 'propertyI', - 'expected' => null, // No definition + yield 'propertyI' => [ // No definition + 'propertyI', // property + null, // expected ]; yield 'propertyJ' => [ - 'property' => 'propertyJ', - 'expected' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false), + 'propertyJ', // property + new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected ]; yield 'propertyK' => [ - 'property' => 'propertyK', - 'expected' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false), + 'propertyK', // property + new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected ]; yield 'propertyL' => [ - 'property' => 'propertyL', - 'expected' => new DataIterableAnnotation(DummyBackedEnum::class, isData: false), + 'propertyL', // property + new DataIterableAnnotation(DummyBackedEnum::class, isData: false), // expected ]; yield 'propertyP' => [ - 'property' => 'propertyP', - 'expected' => new DataIterableAnnotation(Error::class, isData: true), + 'propertyP', // property + new DataIterableAnnotation(Error::class, isData: true), // expected ]; yield 'propertyR' => [ - 'property' => 'propertyR', - 'expected' => new DataIterableAnnotation(Error::class, isData: true), + 'propertyR', // property + new DataIterableAnnotation(Error::class, isData: true), // expected ]; }); @@ -318,10 +318,10 @@ function (string $property, ?DataIterableAnnotation $expected) { ) )->toEqual(new DataIterableAnnotation('float', isData: false, keyType: $keyType)); })->with([ - 'string key' => ['propertyA', 'string'], - 'int key' => ['propertyB', 'int'], - 'array key' => ['propertyC', 'array-key'], - 'int|string key' => ['propertyD', 'int|string'], - 'string|int key' => ['propertyE', 'string|int'], - 'spaces' => ['propertyF', 'int'], + ['propertyA', 'string'], // string key + ['propertyB', 'int'], // int key + ['propertyC', 'array-key'], // array key + ['propertyD', 'int|string'], // int|string key + ['propertyE', 'string|int'], // string|int key + ['propertyF', 'int'], // spaces ]); diff --git a/tests/Support/Caching/CachedDataConfigTest.php b/tests/Support/Caching/CachedDataConfigTest.php index 732031d5b..b9e5535c8 100644 --- a/tests/Support/Caching/CachedDataConfigTest.php +++ b/tests/Support/Caching/CachedDataConfigTest.php @@ -3,11 +3,14 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Cache; use Mockery\MockInterface; +use Spatie\LaravelData\Attributes\WithCastable; +use Spatie\LaravelData\Data; use Spatie\LaravelData\Support\Caching\CachedDataConfig; use Spatie\LaravelData\Support\Caching\DataStructureCache; use Spatie\LaravelData\Support\DataClass; use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Tests\Factories\FakeDataStructureFactory; +use Spatie\LaravelData\Tests\Fakes\Castables\SimpleCastable; use Spatie\LaravelData\Tests\Fakes\SimpleData; function ensureDataWillBeCached() @@ -104,3 +107,32 @@ function (MockInterface $spy) use ($dataClass) { cache()->get('something-just-to-test-the-mock'); }); + +it('is possible to cache data classes with castables using anonymous classes', function () { + ensureDataWillBeCached(); + + $objectDefinition = new class () extends Data { + #[WithCastable(SimpleCastable::class, normalize: true)] + public SimpleCastable $string; + }; + + $dataClass = app(DataConfig::class)->getDataClass($objectDefinition::class); + + expect(isset(invade($dataClass->properties['string']->cast)->cast))->toBeFalse(); + + $objectDefinition::from(['string' => 'Hello world']); + + $dataClass = app(DataConfig::class)->getDataClass($objectDefinition::class); + + $reflection = new ReflectionClass(invade($dataClass->properties['string']->cast)->cast); + + expect($reflection->isAnonymous())->toBeTrue(); + + $dataClass->prepareForCache(); + + app(DataStructureCache::class)->storeDataClass($dataClass); + + $newDataClass = app(DataStructureCache::class)->getDataClass($objectDefinition::class); + + expect(isset(invade($newDataClass->properties['string']->cast)->cast))->toBeFalse(); +}); 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 diff --git a/tests/Support/DataPropertyTest.php b/tests/Support/DataPropertyTest.php index 024a34e39..8664bb2a3 100644 --- a/tests/Support/DataPropertyTest.php +++ b/tests/Support/DataPropertyTest.php @@ -1,5 +1,9 @@ build($reflectionProperty, $reflectionClass, $hasDefaultValue, $defaultValue); + return app(DataPropertyFactory::class)->build( + $reflectionProperty, + $reflectionClass, + $hasDefaultValue, + $defaultValue, + classAutoLazy: $classAutoLazy + ); } it('can get the cast attribute with arguments', function () { @@ -196,6 +208,56 @@ public function __construct( )->toBeTrue(); }); +it('can check if a property is auto-lazy', function () { + expect( + resolveHelper(new class () { + public string $property; + })->autoLazy + )->toBeNull(); + + expect( + resolveHelper(new class () { + #[AutoLazy] + public string $property; + })->autoLazy + )->toBeInstanceOf(AutoLazy::class); + + expect( + resolveHelper(new class () { + #[AutoInertiaLazy] + public string|Lazy $property; + })->autoLazy + )->toBeInstanceOf(AutoInertiaLazy::class); + + expect( + resolveHelper(new class () { + #[AutoWhenLoadedLazy('relation')] + public string|Lazy $property; + })->autoLazy + )->toBeInstanceOf(AutoWhenLoadedLazy::class); + + expect( + resolveHelper(new class () { + #[AutoClosureLazy] + public string $property; + })->autoLazy + )->toBeInstanceOf(AutoClosureLazy::class); +}); + +it('will set a property as auto-lazy when the class is auto-lazy and a lazy type is allowed', function () { + expect( + resolveHelper(new class () { + public string $property; + }, classAutoLazy: new AutoLazy())->autoLazy + )->toBeNull(); + + expect( + resolveHelper(new class () { + public string|Lazy $property; + }, classAutoLazy: new AutoLazy())->autoLazy + )->toBeInstanceOf(AutoLazy::class); +}); + it('wont throw an error if non existing attribute is used on a data class property', function () { expect(NonExistingPropertyAttributeData::from(['property' => 'hello'])->property)->toEqual('hello') ->and(PhpStormAttributeData::from(['property' => 'hello'])->property)->toEqual('hello') diff --git a/tests/Support/DataPropertyTypeTest.php b/tests/Support/DataPropertyTypeTest.php index 402c10469..b0bd3ec93 100644 --- a/tests/Support/DataPropertyTypeTest.php +++ b/tests/Support/DataPropertyTypeTest.php @@ -747,74 +747,74 @@ function (object $class, array $expected) { } )->with(function () { yield 'no type' => [ - 'class' => new class () { - public $property; - }, - 'expected' => [], + new class () { // class + public $property; + }, + [], // expected ]; yield 'mixed' => [ - 'class' => new class () { - public mixed $property; - }, - 'expected' => [], + new class () { // class + public mixed $property; + }, + [], // expected ]; yield 'single' => [ - 'class' => new class () { - public string $property; - }, - 'expected' => ['string' => []], + new class () { // class + public string $property; + }, + ['string' => []], // expected ]; yield 'multi' => [ - 'class' => new class () { - public string|int|bool|float|array $property; - }, - 'expected' => [ - 'string' => [], - 'int' => [], - 'bool' => [], - 'float' => [], - 'array' => [], - ], + new class () { // class + public string|int|bool|float|array $property; + }, + [ + 'string' => [], + 'int' => [], + 'bool' => [], + 'float' => [], + 'array' => [], + ], // expected ]; yield 'data' => [ - 'class' => new class () { - public SimpleData $property; - }, - 'expected' => [ - SimpleData::class => [ - Data::class, - JsonSerializable::class, - Castable::class, - Jsonable::class, - Responsable::class, - Arrayable::class, - AppendableData::class, - ContextableData::class, - BaseData::class, - IncludeableData::class, - ResponsableData::class, - TransformableData::class, - ValidateableData::class, - WrappableData::class, - EmptyData::class, - ], - ], + new class () { // class + public SimpleData $property; + }, + [ + SimpleData::class => [ + Data::class, + JsonSerializable::class, + Castable::class, + Jsonable::class, + Responsable::class, + Arrayable::class, + AppendableData::class, + ContextableData::class, + BaseData::class, + IncludeableData::class, + ResponsableData::class, + TransformableData::class, + ValidateableData::class, + WrappableData::class, + EmptyData::class, + ], + ], // expected ]; yield 'enum' => [ - 'class' => new class () { - public DummyBackedEnum $property; - }, - 'expected' => [ - DummyBackedEnum::class => [ - UnitEnum::class, - BackedEnum::class, - ], - ], + new class () { // class + public DummyBackedEnum $property; + }, + [ + DummyBackedEnum::class => [ + UnitEnum::class, + BackedEnum::class, + ], + ], // expected ]; }); @@ -827,147 +827,147 @@ function (object $class, string $type, bool $accepts) { // Base types yield [ - 'class' => new class () { + new class () { public $property; }, - 'type' => 'string', - 'accepts' => true, + 'string', + true, ]; yield [ - 'class' => new class () { + new class () { public mixed $property; }, - 'type' => 'string', - 'accepts' => true, + 'string', + true, ]; yield [ - 'class' => new class () { + new class () { public string $property; }, - 'type' => 'string', - 'accepts' => true, + 'string', + true, ]; yield [ - 'class' => new class () { + new class () { public bool $property; }, - 'type' => 'bool', - 'accepts' => true, + 'bool', + true, ]; yield [ - 'class' => new class () { + new class () { public int $property; }, - 'type' => 'int', - 'accepts' => true, + 'int', + true, ]; yield [ - 'class' => new class () { + new class () { public float $property; }, - 'type' => 'float', - 'accepts' => true, + 'float', + true, ]; yield [ - 'class' => new class () { + new class () { public array $property; }, - 'type' => 'array', - 'accepts' => true, + 'array', + true, ]; yield [ - 'class' => new class () { + new class () { public string $property; }, - 'type' => 'array', - 'accepts' => false, + 'array', + false, ]; // Objects yield [ - 'class' => new class () { + new class () { public SimpleData $property; }, - 'type' => SimpleData::class, - 'accepts' => true, + SimpleData::class, + true, ]; yield [ - 'class' => new class () { + new class () { public SimpleData $property; }, - 'type' => ComplicatedData::class, - 'accepts' => false, + ComplicatedData::class, + false, ]; // Objects with inheritance yield 'simple inheritance' => [ - 'class' => new class () { + new class () { public Data $property; }, - 'type' => SimpleData::class, - 'accepts' => true, + SimpleData::class, + true, ]; yield 'reversed inheritance' => [ - 'class' => new class () { + new class () { public SimpleData $property; }, - 'type' => Data::class, - 'accepts' => false, + Data::class, + false, ]; yield 'false inheritance' => [ - 'class' => new class () { + new class () { public Model $property; }, - 'type' => SimpleData::class, - 'accepts' => false, + SimpleData::class, + false, ]; // Objects with interfaces yield 'simple interface implementation' => [ - 'class' => new class () { + new class () { public DateTimeInterface $property; }, - 'type' => DateTime::class, - 'accepts' => true, + DateTime::class, + true, ]; yield 'reversed interface implementation' => [ - 'class' => new class () { + new class () { public DateTime $property; }, - 'type' => DateTimeInterface::class, - 'accepts' => false, + DateTimeInterface::class, + false, ]; yield 'false interface implementation' => [ - 'class' => new class () { + new class () { public Model $property; }, - 'type' => DateTime::class, - 'accepts' => false, + DateTime::class, + false, ]; // Enums yield [ - 'class' => new class () { + new class () { public DummyBackedEnum $property; }, - 'type' => DummyBackedEnum::class, - 'accepts' => true, + DummyBackedEnum::class, + true, ]; }); @@ -978,67 +978,67 @@ function (object $class, mixed $value, bool $accepts) { } )->with(function () { yield [ - 'class' => new class () { + new class () { public ?string $property; }, - 'value' => null, - 'accepts' => true, + null, + true, ]; yield [ - 'class' => new class () { + new class () { public string $property; }, - 'value' => 'Hello', - 'accepts' => true, + 'Hello', + true, ]; yield [ - 'class' => new class () { + new class () { public string $property; }, - 'value' => 3.14, - 'accepts' => false, + 3.14, + false, ]; yield [ - 'class' => new class () { + new class () { public mixed $property; }, - 'value' => 3.14, - 'accepts' => true, + 3.14, + true, ]; yield [ - 'class' => new class () { + new class () { public Data $property; }, - 'value' => new SimpleData('Hello'), - 'accepts' => true, + new SimpleData('Hello'), + true, ]; yield [ - 'class' => new class () { + new class () { public SimpleData $property; }, - 'value' => new SimpleData('Hello'), - 'accepts' => true, + new SimpleData('Hello'), + true, ]; yield [ - 'class' => new class () { + new class () { public SimpleData $property; }, - 'value' => new SimpleDataWithMappedProperty('Hello'), - 'accepts' => false, + new SimpleDataWithMappedProperty('Hello'), + false, ]; yield [ - 'class' => new class () { + new class () { public DummyBackedEnum $property; }, - 'value' => DummyBackedEnum::FOO, - 'accepts' => true, + DummyBackedEnum::FOO, + true, ]; }); @@ -1051,35 +1051,35 @@ function (object $class, string $type, ?string $expectedType) { } )->with(function () { yield [ - 'class' => new class () { + new class () { public SimpleData $property; }, - 'type' => SimpleData::class, - 'expectedType' => SimpleData::class, + SimpleData::class, + SimpleData::class, ]; yield [ - 'class' => new class () { + new class () { public SimpleData $property; }, - 'type' => Data::class, - 'expectedType' => SimpleData::class, + Data::class, + SimpleData::class, ]; yield [ - 'class' => new class () { + new class () { public DummyBackedEnum $property; }, - 'type' => BackedEnum::class, - 'expectedType' => DummyBackedEnum::class, + BackedEnum::class, + DummyBackedEnum::class, ]; yield [ - 'class' => new class () { + new class () { public SimpleData $property; }, - 'type' => DataCollection::class, - 'expectedType' => null, + DataCollection::class, + null, ]; }); diff --git a/tests/Support/DataReturnTypeTest.php b/tests/Support/DataReturnTypeTest.php index 57e1e301f..37461bdf5 100644 --- a/tests/Support/DataReturnTypeTest.php +++ b/tests/Support/DataReturnTypeTest.php @@ -63,21 +63,21 @@ public function none() expect($factory->buildFromValue($value, TestReturnTypeSubject::class, false))->toEqual($expected); })->with(function () { yield 'array' => [ - 'methodName' => 'array', - 'typeName' => 'array', - 'value' => [], + 'array', // methodName + 'array', // typeName + [], new DataType( type: new NamedType('array', true, [], DataTypeKind::Array, null, null, 'array', null, null), isNullable: false, isMixed: false, kind: DataTypeKind::Array, - ), + ), // value ]; yield 'collection' => [ - 'methodName' => 'collection', - 'typeName' => Collection::class, - 'value' => collect(), + 'collection', // methodName + Collection::class, // typeName + collect(), new DataType( type: new NamedType(Collection::class, false, [ ArrayAccess::class, @@ -94,13 +94,13 @@ public function none() isNullable: false, isMixed: false, kind: DataTypeKind::Enumerable, - ), + ), // value ]; yield 'data collection' => [ - 'methodName' => 'dataCollection', - 'typeName' => DataCollection::class, - 'value' => new DataCollection(SimpleData::class, []), + 'dataCollection', // methodName + DataCollection::class, // typeName + new DataCollection(SimpleData::class, []), new DataType( type: new NamedType(DataCollection::class, false, [ Illuminate\Contracts\Support\Responsable::class, @@ -122,7 +122,7 @@ public function none() isNullable: false, isMixed: false, kind: DataTypeKind::DataCollection, - ), + ), // value ]; }); diff --git a/tests/Support/Partials/PartialTest.php b/tests/Support/Partials/PartialTest.php index 325a5858a..66aebaf1f 100644 --- a/tests/Support/Partials/PartialTest.php +++ b/tests/Support/Partials/PartialTest.php @@ -16,54 +16,54 @@ function rootPartialsProvider(): Generator { yield "empty" => [ - 'partials' => '', - 'expected' => [], + '', // partials + [], // expected ]; yield "root property" => [ - 'partials' => 'name', - 'expected' => [new FieldsPartialSegment(['name'])], + 'name', // partials + [new FieldsPartialSegment(['name'])], // expected ]; yield "root multi-property" => [ - 'partials' => '{name, age}', - 'expected' => [new FieldsPartialSegment(['name', 'age'])], + '{name, age}', // partials + [new FieldsPartialSegment(['name', 'age'])], // expected ]; yield "root star" => [ - 'partials' => '*', - 'expected' => [new AllPartialSegment()], + '*', // partials + [new AllPartialSegment()], // expected ]; } function nestedPartialsProvider(): Generator { yield "nested property" => [ - 'partials' => 'struct.name', - 'expected' => [new NestedPartialSegment('struct'), new FieldsPartialSegment(['name'])], + 'struct.name', // partials + [new NestedPartialSegment('struct'), new FieldsPartialSegment(['name'])], // expected ]; yield "nested multi-property" => [ - 'partials' => 'struct.{name, age}', - 'expected' => [new NestedPartialSegment('struct'), new FieldsPartialSegment(['name', 'age'])], + 'struct.{name, age}', // partials + [new NestedPartialSegment('struct'), new FieldsPartialSegment(['name', 'age'])], // expected ]; yield "nested star" => [ - 'partials' => 'struct.*', - 'expected' => [new NestedPartialSegment('struct'), new AllPartialSegment()], + 'struct.*', // partials + [new NestedPartialSegment('struct'), new AllPartialSegment()], // expected ]; } function invalidPartialsProvider(): Generator { yield "nested property on all" => [ - 'partials' => '*.name', - 'expected' => [new AllPartialSegment()], + '*.name', // partials + [new AllPartialSegment()], // expected ]; yield "nested property on multi-property" => [ - 'partials' => '{name, age}.name', - 'expected' => [new FieldsPartialSegment(['name', 'age'])], + '{name, age}.name', // partials + [new FieldsPartialSegment(['name', 'age'])], // expected ]; } diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index e71846a6d..84741c068 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -20,6 +20,7 @@ use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\MapInputName; use Spatie\LaravelData\Attributes\MapName; +use Spatie\LaravelData\Attributes\MergeValidationRules; use Spatie\LaravelData\Attributes\Validation\ArrayType; use Spatie\LaravelData\Attributes\Validation\Bail; use Spatie\LaravelData\Attributes\Validation\BooleanType; @@ -2530,6 +2531,82 @@ public function __construct( ]); }); +it('it will merge validation rules', function () { + #[MergeValidationRules] + class TestNestedDataWithMergedRules extends SimpleData + { + public static function rules(ValidationContext $context): array + { + return [ + 'string' => ['max:10', 'min:2'], + ]; + } + } + + #[MergeValidationRules] + class TestDataWithMergedRuleset extends Data + { + public function __construct( + #[Max(10)] + public string $array_rules, + #[Max(10)] + public string $string_rules, + #[WithoutValidation] + public string $without_validation, + public TestNestedDataWithMergedRules $nested + ) { + } + + public static function rules(): array + { + return [ + 'array_rules' => ['min:2', 'alpha'], + 'string_rules' => 'min:2|alpha', + ]; + } + } + + DataValidationAsserter::for(TestDataWithMergedRuleset::class) + ->assertRules([ + 'array_rules' => ['required', 'string', 'max:10', 'min:2', 'alpha'], + 'string_rules' => ['required', 'string', 'max:10', 'min:2', 'alpha'], + 'nested' => ['required', 'array'], + 'nested.string' => ['required', 'string', 'max:10', 'min:2'], + ], []) + ->assertOk([ + 'array_rules' => 'Ruben', + 'string_rules' => 'Ruben', + 'nested' => ['string' => 'Ruben'], + ]) + ->assertErrors([ + 'array_rules' => 'r', + 'string_rules' => 'r', + 'nested' => ['string' => 'r'], + ], [ + 'array_rules' => [__('validation.min.string', ['attribute' => 'array rules', 'min' => 2])], + 'string_rules' => [__('validation.min.string', ['attribute' => 'string rules', 'min' => 2])], + 'nested.string' => [__('validation.min.string', ['attribute' => 'nested.string', 'min' => 2])], + ]) + ->assertErrors([ + 'array_rules' => 'rubenvanassche', + 'string_rules' => 'rubenvanassche', + 'nested' => ['string' => 'rubenvanassche'], + ], [ + 'array_rules' => [__('validation.max.string', ['attribute' => 'array rules', 'max' => 10])], + 'string_rules' => [__('validation.max.string', ['attribute' => 'string rules', 'max' => 10])], + 'nested.string' => [__('validation.max.string', ['attribute' => 'nested.string', 'max' => 10])], + ]) + ->assertErrors([ + 'array_rules' => null, + 'string_rules' => null, + 'nested' => ['string' => null], + ], [ + 'array_rules' => [__('validation.required', ['attribute' => 'array rules'])], + 'string_rules' => [__('validation.required', ['attribute' => 'string rules'])], + 'nested.string' => [__('validation.required', ['attribute' => 'nested.string'])], + ]); +}); + describe('property-morphable validation tests', function () { abstract class TestValidationAbstractPropertyMorphableData extends Data implements PropertyMorphableData {