diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 9c7e095a..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 2c94ab4d..5d247a23 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -15,7 +15,7 @@ jobs: name: phpstan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml index 4917b37e..2ec04483 100644 --- a/.github/workflows/php-cs-fixer.yml +++ b/.github/workflows/php-cs-fixer.yml @@ -8,7 +8,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.head_ref }} diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index b6b073e1..572ac9da 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -16,7 +16,7 @@ jobs: name: phpstan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 359aa082..e9ae307c 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index b20f3b6f..94df2c33 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: main diff --git a/CHANGELOG.md b/CHANGELOG.md index ab3448f5..8ab90324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,31 @@ All notable changes to `laravel-data` will be documented in this file. - Allow creating data objects using `from` without parameters - Add support for a Dto and Resource object +## 3.9.0 - 2023-09-15 + +- Fix an issue where computed values could not be set as null +- Fix for no rules created on optional|nullable Data object and Collection (#532) +- Add `CustomValidationAttribute`'s +- Copy partial trees when using array access on a collection + +## 3.8.1 - 2023-08-11 + +- fix abstract json cast format + +## 3.8.0 - 2023-08-09 + +- Add Hidden Attribute (#505) +- Add Null value support for RequiredUnless Validation (#525) +- Add abstract eloquent casts (#526) + +## 3.7.1 - 2023-08-04 + +- fix target namespace when creating files with Laravel Idea (#497) +- allow collection to be created passing null (#507) +- add Ulid validation rule (#510) + -add TARGET_PARAMETER to Attribute for improved Validation (#523) +>>>>>>> main + ## 3.7.0 - 2023-07-05 - Add support for better exception messages when parameters are missing diff --git a/docs/advanced-usage/creating-a-cast.md b/docs/advanced-usage/creating-a-cast.md index a8c7b21a..f2cfd090 100644 --- a/docs/advanced-usage/creating-a-cast.md +++ b/docs/advanced-usage/creating-a-cast.md @@ -64,11 +64,11 @@ class Email implements Castable } - public static function dataCastUsing(...$arguments) + public static function dataCastUsing(...$arguments): Cast { return new class implements Cast { public function cast(DataProperty $property, mixed $value, array $context): mixed { - return new self($value); + return new Email($value); } }; } diff --git a/docs/advanced-usage/eloquent-casting.md b/docs/advanced-usage/eloquent-casting.md index 09f89f61..c9753e9a 100644 --- a/docs/advanced-usage/eloquent-casting.md +++ b/docs/advanced-usage/eloquent-casting.md @@ -3,7 +3,7 @@ title: Eloquent casting weight: 1 --- -Since data objects can be created from array's and be easily transformed into array's back again, they are excellent to be used +Since data objects can be created from arrays and be easily transformed into arrays back again, they are excellent to be used with [Eloquent casts](https://laravel.com/docs/8.x/eloquent-mutators#custom-casts): ```php @@ -40,6 +40,94 @@ This will internally be converted to a data object which you can later retrieve Song::findOrFail($id)->artist; // ArtistData object ``` +### Abstract data objects + +Sometimes you have an abstract parent data object with multiple child data objects, for example: + +```php +abstract class RecordConfig extends Data +{ + public function __construct( + public int $tracks, + ) {} +} + +class CdRecordConfig extends RecordConfig +{ + public function __construct( + int $tracks + public int $bytes, + ) { + parent::__construct($tracks); + } +} + +class VinylRecordConfig extends RecordConfig +{ + public function __construct( + int $tracks + public int $rpm, + ) { + parent::__construct($tracks); + } +} +``` + +A model can have a JSON field which is either one of these data objects: + +```php +class Record extends Model +{ + protected $casts = [ + 'config' => RecordConfig::class, + ]; +} +``` + +You can then store either a `CdRecordConfig` or a `VinylRecord` in the `config` field: + +```php +$cdRecord = Record::create([ + 'config' => new CdRecordConfig(tracks: 12, bytes: 1000), +]); + +$vinylRecord = Record::create([ + 'config' => new VinylRecordConfig(tracks: 12, rpm: 33), +]); + +$cdRecord->config; // CdRecordConfig object +$vinylRecord->config; // VinylRecordConfig object +``` + +When a data object class is abstract and used as an Eloquent cast then this feature will work out of the box. + +The child data object value of the model will be stored in the database as a JSON string with the class name as the key: + +```json +{ + "type": "\\App\\Data\\CdRecordConfig", + "value": { + "tracks": 12, + "bytes": 1000 + } +} +``` + +When retrieving the model, the data object will be instantiated based on the `type` key in the JSON string. + +#### Abstract data class morphs + +By default, the `type` key in the JSON string will be the fully qualified class name of the child data object. This can break your application quite easily when you refactor your code. To prevent this, you can add a morph map like with [Eloquent models](https://laravel.com/docs/10.x/eloquent-relationships#polymorphic-relationships). Within your `AppServiceProvivder` you can add the following mapping: + +```php +use Spatie\LaravelData\Support\DataConfig; + +app(DataConfig::class)->enforceMorphMap([ + 'cd_record_config' => CdRecordConfig::class, + 'vinyl_record_config' => VinylRecordConfig::class, +]); +``` + ## Casting data collections It is also possible to store data collections in an Eloquent model: diff --git a/docs/advanced-usage/validation-attributes.md b/docs/advanced-usage/validation-attributes.md index 66692a99..578db49e 100644 --- a/docs/advanced-usage/validation-attributes.md +++ b/docs/advanced-usage/validation-attributes.md @@ -20,19 +20,24 @@ class SongData extends Data ## Creating your validation attribute -A validation attribute is a class that extends `ValidationRule` and returns an array of validation rules when the `getRules` method is called: +It is possible to create your own validation attribute by extending the `CustomValidationAttribute` class, this class has a `getRules` method that returns the rules that should be applied to the property. ```php -#[Attribute(Attribute::TARGET_PROPERTY)] -class CustomRule extends ValidationRule +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] +class CustomRule extends CustomValidationAttribute { - public function getRules(): array + /** + * @return array|object|string + */ + public function getRules(ValidationPath $path): array|object|string; { return [new CustomRule()]; } } ``` +Quick note: you can only use these rules as an attribute, not as a class rule within the static `rules` method of the data class. + ## Available validation attributes ### Accepted diff --git a/docs/as-a-data-transfer-object/casts.md b/docs/as-a-data-transfer-object/casts.md index 28ba24ce..435bb5ad 100644 --- a/docs/as-a-data-transfer-object/casts.md +++ b/docs/as-a-data-transfer-object/casts.md @@ -21,10 +21,10 @@ class SongData extends Data The `Format` property here is an `Enum` and looks like this: ```php -enum Format { - case cd; - case vinyl; - case cassette; +enum Format: string { + case cd = 'cd'; + case vinyl = 'vinyl'; + case cassette = 'cassette'; } ``` @@ -114,6 +114,8 @@ class SongData extends Data } ``` +Tip: we can also remove the `EnumCast` since the package will automatically cast enums because they're a native PHP type, but this made the example easy to understand. + ## Creating your own casts It is possible to create your casts. You can read more about this in the [advanced chapter](/docs/laravel-data/v3/advanced-usage/creating-a-cast). diff --git a/docs/as-a-data-transfer-object/creating-a-data-object.md b/docs/as-a-data-transfer-object/creating-a-data-object.md index 6c0f224f..ef226167 100644 --- a/docs/as-a-data-transfer-object/creating-a-data-object.md +++ b/docs/as-a-data-transfer-object/creating-a-data-object.md @@ -46,7 +46,7 @@ SongData::from(Song::firstOrFail($id)); Data can also be created from JSON strings: ```php -SongData::from('{"name" : "Never Gonna Give You Up","artist" : "Rick Astley"}'); +SongData::from('{"title" : "Never Gonna Give You Up","artist" : "Rick Astley"}'); ``` The package will find the required properties within the model and use them to construct the data object. diff --git a/docs/changelog.md b/docs/changelog.md index ba575912..dc9856e6 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,6 @@ --- title: Changelog -weight: 6 +weight: 7 --- All notable changes to laravel-data are documented [on GitHub](https://github.com/spatie/laravel-data/blob/main/CHANGELOG.md) diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 154c483e..a1bb1160 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -85,7 +85,7 @@ PostData::from(Post::findOrFail($id)); ## Using requests -Let's say we have a Laravel request coming from the front with these properties. Our controller would then would validate these properties and then it would store them in a model; this can be done as such: +Let's say we have a Laravel request coming from the front with these properties. Our controller would then validate these properties and then it would store them in a model; this can be done as such: ```php class DataController diff --git a/docs/questions-issues.md b/docs/questions-issues.md index 102de060..1c76ae31 100644 --- a/docs/questions-issues.md +++ b/docs/questions-issues.md @@ -1,6 +1,6 @@ --- title: Questions and issues -weight: 5 +weight: 6 --- Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving Laravel Data? Feel free to [create an issue on GitHub](https://github.com/spatie/laravel-data/issues), we'll try to address it as soon as possible. diff --git a/docs/third-party-packages.md b/docs/third-party-packages.md new file mode 100644 index 00000000..8ef7ca7c --- /dev/null +++ b/docs/third-party-packages.md @@ -0,0 +1,10 @@ +--- +title: Third party packages +weight: 5 +--- + +Some community members created packages that extend the functionality of Laravel Data. Here's a list of them: + +- [laravel-data-openapi-generator](https://github.com/xolvionl/laravel-data-openapi-generator) + +Created a package yourself that you want to add to this list? Send us a PR! diff --git a/src/Attributes/Hidden.php b/src/Attributes/Hidden.php new file mode 100644 index 00000000..f55f5405 --- /dev/null +++ b/src/Attributes/Hidden.php @@ -0,0 +1,10 @@ +|object|string + */ + abstract public function getRules(ValidationPath $path): array|object|string; +} diff --git a/src/Attributes/Validation/RequiredUnless.php b/src/Attributes/Validation/RequiredUnless.php index 6cc607ce..866ea753 100644 --- a/src/Attributes/Validation/RequiredUnless.php +++ b/src/Attributes/Validation/RequiredUnless.php @@ -17,8 +17,8 @@ class RequiredUnless extends StringValidationAttribute implements RequiringRule protected string|array $values; public function __construct( - string|FieldReference $field, - array|string|BackedEnum|RouteParameterReference ...$values + string|FieldReference $field, + null|array|string|BackedEnum|RouteParameterReference ...$values ) { $this->field = $this->parseFieldReference($field); $this->values = Arr::flatten($values); diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index 4ccf6e7a..e79c72d9 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -96,6 +96,14 @@ public static function prepareForPipeline(Collection $properties): Collection return $properties; } + public function getMorphClass(): string + { + /** @var class-string<\Spatie\LaravelData\Contracts\BaseData> $class */ + $class = static::class; + + return app(DataConfig::class)->morphMap->getDataClassAlias($class) ?? $class; + } + public function __sleep(): array { return app(DataConfig::class)->getDataClass(static::class) diff --git a/src/Contracts/BaseData.php b/src/Contracts/BaseData.php index 2f51def4..0aff149f 100644 --- a/src/Contracts/BaseData.php +++ b/src/Contracts/BaseData.php @@ -32,4 +32,7 @@ public static function normalizers(): array; public static function prepareForPipeline(\Illuminate\Support\Collection $properties): \Illuminate\Support\Collection; public static function pipeline(): DataPipeline; + public static function empty(array $extra = []): array; + + public function getMorphClass(): string; } diff --git a/src/DataCollection.php b/src/DataCollection.php index ca8fb425..ad8063c9 100644 --- a/src/DataCollection.php +++ b/src/DataCollection.php @@ -14,9 +14,11 @@ use Spatie\LaravelData\Concerns\WrappableData; use Spatie\LaravelData\Contracts\BaseData; use Spatie\LaravelData\Contracts\DataCollectable; +use Spatie\LaravelData\Contracts\IncludeableData as IncludeableDataContract; use Spatie\LaravelData\Exceptions\CannotCastData; use Spatie\LaravelData\Exceptions\InvalidDataCollectionOperation; use Spatie\LaravelData\Support\EloquentCasts\DataCollectionEloquentCast; +use Spatie\TypeScriptTransformer\Attributes\LiteralTypeScriptType; /** * @template TKey of array-key @@ -103,7 +105,13 @@ public function offsetGet($offset): mixed throw InvalidDataCollectionOperation::create(); } - return $this->items->offsetGet($offset); + $data = $this->items->offsetGet($offset); + + if ($data instanceof IncludeableDataContract) { + $data->getDataContext()->partialsDefinition->merge($this->getPartialsDefinition()); + } + + return $data; } /** diff --git a/src/Resolvers/DataFromArrayResolver.php b/src/Resolvers/DataFromArrayResolver.php index ad931db6..a02ce5f1 100644 --- a/src/Resolvers/DataFromArrayResolver.php +++ b/src/Resolvers/DataFromArrayResolver.php @@ -41,10 +41,10 @@ public function execute(string $class, Collection $properties): BaseData $dataClass ->properties - ->filter( - fn (DataProperty $property) => ! $property->isPromoted && - ! $property->isReadonly && - $properties->has($property->name) + ->reject( + fn (DataProperty $property) => $property->isPromoted + || $property->isReadonly + || ! $properties->has($property->name) ) ->each(function (DataProperty $property) use ($properties, $data) { if ($property->type->isOptional @@ -54,6 +54,13 @@ public function execute(string $class, Collection $properties): BaseData return; } + if ($property->computed + && $property->type->isNullable() + && $properties->get($property->name) === null + ) { + return; // Nullable properties get assigned null by default + } + if ($property->computed) { throw CannotSetComputedValue::create($property); } diff --git a/src/Resolvers/DataValidationRulesResolver.php b/src/Resolvers/DataValidationRulesResolver.php index 2b60b39e..20915c37 100644 --- a/src/Resolvers/DataValidationRulesResolver.php +++ b/src/Resolvers/DataValidationRulesResolver.php @@ -102,11 +102,18 @@ protected function resolveDataSpecificRules( ValidationPath $propertyPath, DataRules $dataRules, ): void { - if ($dataProperty->type->isOptional && Arr::has($fullPayload, $propertyPath->get()) === false) { - return; - } + $isOptionalAndEmpty = $dataProperty->type->isOptional && Arr::has($fullPayload, $propertyPath->get()) === false; + $isNullableAndEmpty = $dataProperty->type->isNullable() && Arr::get($fullPayload, $propertyPath->get()) === null; + + if ($isOptionalAndEmpty || $isNullableAndEmpty) { + $this->resolveToplevelRules( + $dataProperty, + $fullPayload, + $path, + $propertyPath, + $dataRules + ); - if ($dataProperty->type->isNullable() && Arr::get($fullPayload, $propertyPath->get()) === null) { return; } @@ -140,15 +147,14 @@ protected function resolveDataObjectSpecificRules( ValidationPath $propertyPath, DataRules $dataRules, ): void { - $toplevelRules = $this->inferRulesForDataProperty( + $this->resolveToplevelRules( $dataProperty, - PropertyRules::create(ArrayType::create()), $fullPayload, $path, + $propertyPath, + $dataRules ); - $dataRules->add($propertyPath, $toplevelRules); - $this->execute( $dataProperty->type->dataClass, $fullPayload, @@ -164,15 +170,15 @@ protected function resolveDataCollectionSpecificRules( ValidationPath $propertyPath, DataRules $dataRules, ): void { - $toplevelRules = $this->inferRulesForDataProperty( + $this->resolveToplevelRules( $dataProperty, - PropertyRules::create(Present::create(), ArrayType::create()), $fullPayload, $path, + $propertyPath, + $dataRules, + shouldBePresent: true ); - $dataRules->add($propertyPath, $toplevelRules); - $dataRules->addCollection($propertyPath, Rule::forEach(function (mixed $value, mixed $attribute) use ($fullPayload, $dataProperty) { if (! is_array($value)) { return ['array']; @@ -191,6 +197,33 @@ protected function resolveDataCollectionSpecificRules( })); } + protected function resolveToplevelRules( + DataProperty $dataProperty, + array $fullPayload, + ValidationPath $path, + ValidationPath $propertyPath, + DataRules $dataRules, + bool $shouldBePresent = false + ): void { + $rules = []; + + if ($shouldBePresent) { + $rules[] = Present::create(); + } + + $rules[] = ArrayType::create(); + + $toplevelRules = $this->inferRulesForDataProperty( + $dataProperty, + PropertyRules::create(...$rules), + $fullPayload, + $path, + ); + + $dataRules->add($propertyPath, $toplevelRules); + } + + protected function resolveOverwrittenRules( DataClass $class, array $fullPayload, diff --git a/src/Resolvers/TransformedDataResolver.php b/src/Resolvers/TransformedDataResolver.php index 3482053a..ce01ffd3 100644 --- a/src/Resolvers/TransformedDataResolver.php +++ b/src/Resolvers/TransformedDataResolver.php @@ -55,6 +55,10 @@ private function transform(BaseData&TransformableData $data, TransformationConte ->reduce(function (array $payload, DataProperty $property) use ($data, $context) { $name = $property->name; + if ($property->hidden) { + return $payload; + } + if (! $this->shouldIncludeProperty($name, $data->{$name}, $context)) { return $payload; } diff --git a/src/Support/DataClass.php b/src/Support/DataClass.php index f4c2918a..1264e650 100644 --- a/src/Support/DataClass.php +++ b/src/Support/DataClass.php @@ -38,6 +38,7 @@ public function __construct( public readonly Collection $methods, public readonly ?DataMethod $constructorMethod, public readonly bool $isReadonly, + public readonly bool $isAbstract, public readonly bool $appendable, public readonly bool $includeable, public readonly bool $responsable, @@ -83,6 +84,7 @@ public static function create(ReflectionClass $class): self methods: self::resolveMethods($class), constructorMethod: DataMethod::createConstructor($constructor, $properties), isReadonly: method_exists($class, 'isReadOnly') && $class->isReadOnly(), + isAbstract: $class->isAbstract(), appendable: $class->implementsInterface(AppendableData::class), includeable: $class->implementsInterface(IncludeableData::class), responsable: $class->implementsInterface(ResponsableData::class), diff --git a/src/Support/DataClassMorphMap.php b/src/Support/DataClassMorphMap.php new file mode 100644 index 00000000..d1e604a7 --- /dev/null +++ b/src/Support/DataClassMorphMap.php @@ -0,0 +1,55 @@ +> */ + protected array $map = []; + + /** @var array< class-string, string> */ + protected array $reversedMap = []; + + + /** + * @param string $alias + * @param class-string $class + */ + public function add( + string $alias, + string $class + ): self { + $this->map[$alias] = $class; + $this->reversedMap[$class] = $alias; + + return $this; + } + + /** + * @param array> $map + */ + public function merge(array $map): self + { + foreach ($map as $alias => $class) { + $this->add($alias, $class); + } + + return $this; + } + + public function getMorphedDataClass(string $alias): ?string + { + return $this->map[$alias] ?? null; + } + + + /** + * @param class-string $class + */ + public function getDataClassAlias(string $class): ?string + { + return $this->reversedMap[$class] ?? null; + } +} diff --git a/src/Support/DataConfig.php b/src/Support/DataConfig.php index 3397ff56..dd6c2214 100644 --- a/src/Support/DataConfig.php +++ b/src/Support/DataConfig.php @@ -4,6 +4,7 @@ use ReflectionClass; use Spatie\LaravelData\Casts\Cast; +use Spatie\LaravelData\Contracts\BaseData; use Spatie\LaravelData\Transformers\Transformer; class DataConfig @@ -23,6 +24,8 @@ class DataConfig /** @var \Spatie\LaravelData\RuleInferrers\RuleInferrer[] */ protected array $ruleInferrers; + public readonly DataClassMorphMap $morphMap; + public function __construct(array $config) { $this->ruleInferrers = array_map( @@ -37,6 +40,8 @@ public function __construct(array $config) foreach ($config['casts'] ?? [] as $castable => $cast) { $this->casts[ltrim($castable, ' \\')] = app($cast); } + + $this->morphMap = new DataClassMorphMap(); } public function getDataClass(string $class): DataClass @@ -50,7 +55,7 @@ public function getDataClass(string $class): DataClass public function getResolvedDataPipeline(string $class): ResolvedDataPipeline { - if (array_key_exists($class, $this->resolvedDataPipelines)) { + if (array_key_exists($class, $this->resolvedDataPipelines)) { return $this->resolvedDataPipelines[$class]; } @@ -94,6 +99,14 @@ public function getRuleInferrers(): array return $this->ruleInferrers; } + /** + * @param array> $map + */ + public function enforceMorphMap(array $map): void + { + $this->morphMap->merge($map); + } + public function reset(): self { $this->dataClasses = []; diff --git a/src/Support/DataProperty.php b/src/Support/DataProperty.php index f50288ab..6d6b1275 100644 --- a/src/Support/DataProperty.php +++ b/src/Support/DataProperty.php @@ -7,6 +7,7 @@ use ReflectionProperty; use Spatie\LaravelData\Attributes\Computed; use Spatie\LaravelData\Attributes\GetsCast; +use Spatie\LaravelData\Attributes\Hidden; use Spatie\LaravelData\Attributes\WithoutValidation; use Spatie\LaravelData\Attributes\WithTransformer; use Spatie\LaravelData\Casts\Cast; @@ -27,6 +28,7 @@ public function __construct( public readonly DataType $type, public readonly bool $validate, public readonly bool $computed, + public readonly bool $hidden, public readonly bool $isPromoted, public readonly bool $isReadonly, public readonly bool $hasDefaultValue, @@ -69,6 +71,10 @@ public static function create( fn (object $attribute) => $attribute instanceof Computed ); + $hidden = $attributes->contains( + fn (object $attribute) => $attribute instanceof Hidden + ); + return new self( name: $property->name, className: $property->class, @@ -77,6 +83,7 @@ className: $property->class, fn (object $attribute) => $attribute instanceof WithoutValidation ) && ! $computed, computed: $computed, + hidden: $hidden, isPromoted: $property->isPromoted(), isReadonly: $property->isReadOnly(), hasDefaultValue: $property->isPromoted() ? $hasDefaultValue : $property->hasDefaultValue(), diff --git a/src/Support/EloquentCasts/DataEloquentCast.php b/src/Support/EloquentCasts/DataEloquentCast.php index 7d156833..bc14fb5f 100644 --- a/src/Support/EloquentCasts/DataEloquentCast.php +++ b/src/Support/EloquentCasts/DataEloquentCast.php @@ -6,15 +6,19 @@ use Spatie\LaravelData\Contracts\BaseData; use Spatie\LaravelData\Contracts\TransformableData; use Spatie\LaravelData\Exceptions\CannotCastData; +use Spatie\LaravelData\Support\DataConfig; class DataEloquentCast implements CastsAttributes { + protected DataConfig $dataConfig; + public function __construct( /** @var class-string<\Spatie\LaravelData\Contracts\BaseData> $dataClass */ protected string $dataClass, /** @var string[] $arguments */ protected array $arguments = [] ) { + $this->dataConfig = app(DataConfig::class); } public function get($model, string $key, $value, array $attributes): ?BaseData @@ -29,6 +33,13 @@ public function get($model, string $key, $value, array $attributes): ?BaseData $payload = json_decode($value, true, flags: JSON_THROW_ON_ERROR); + if ($this->isAbstractClassCast()) { + /** @var class-string $dataClass */ + $dataClass = $this->dataConfig->morphMap->getMorphedDataClass($payload['type']) ?? $payload['type']; + + return $dataClass::from($payload['data']); + } + return ($this->dataClass)::from($payload); } @@ -38,7 +49,9 @@ public function set($model, string $key, $value, array $attributes): ?string return null; } - if (is_array($value)) { + $isAbstractClassCast = $this->isAbstractClassCast(); + + if (is_array($value) && ! $isAbstractClassCast) { $value = ($this->dataClass)::from($value); } @@ -50,6 +63,18 @@ public function set($model, string $key, $value, array $attributes): ?string throw CannotCastData::shouldBeTransformableData($model::class, $key); } + if ($isAbstractClassCast) { + return json_encode([ + 'type' => $this->dataConfig->morphMap->getDataClassAlias($value::class) ?? $value::class, + 'data' => json_decode($value->toJson(), associative: true, flags: JSON_THROW_ON_ERROR), + ]); + } + return $value->toJson(); } + + protected function isAbstractClassCast(): bool + { + return $this->dataConfig->getDataClass($this->dataClass)->isAbstract; + } } diff --git a/src/Support/Partials/PartialsDefinition.php b/src/Support/Partials/PartialsDefinition.php index 6d9a4852..74c26024 100644 --- a/src/Support/Partials/PartialsDefinition.php +++ b/src/Support/Partials/PartialsDefinition.php @@ -19,4 +19,14 @@ public function __construct( public array $except = [], ) { } + + public function merge(PartialsDefinition $other): self + { + $this->includes = array_merge($this->includes, $other->includes); + $this->only = array_merge($this->only, $other->only); + $this->excludes = array_merge($this->excludes, $other->excludes); + $this->except = array_merge($this->except, $other->except); + + return $this; + } } diff --git a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php index 40532a7b..267329fc 100644 --- a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php +++ b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php @@ -2,9 +2,25 @@ namespace Spatie\LaravelData\Support\TypeScriptTransformer; -use Spatie\TypeScriptTransformer\Laravel\Transformers\DataClassTransformer; +use ReflectionClass; +use ReflectionProperty; +use Spatie\LaravelData\Contracts\BaseData; +use Spatie\LaravelData\Support\DataConfig; +use Spatie\TypeScriptTransformer\Laravel\ClassPropertyProcessors\RemoveDataLazyTypeClassPropertyProcessor; +use Spatie\TypeScriptTransformer\Transformers\ClassTransformer; +use Spatie\TypeScriptTransformer\TypeScript\TypeScriptNode; -class DataTypeScriptTransformer extends DataClassTransformer +class DataTypeScriptTransformer extends ClassTransformer { - // TODO implement this ourselves + protected function shouldTransform(ReflectionClass $reflection): bool + { + return $reflection->implementsInterface(BaseData::class); + } + + protected function classPropertyProcessors(): array + { + return array_merge(parent::classPropertyProcessors(), [ + new DataUtilitiesClassPropertyProcessor(), + ]); + } } diff --git a/src/Support/TypeScriptTransformer/DataUtilitiesClassPropertyProcessor.php b/src/Support/TypeScriptTransformer/DataUtilitiesClassPropertyProcessor.php new file mode 100644 index 00000000..5b7315e2 --- /dev/null +++ b/src/Support/TypeScriptTransformer/DataUtilitiesClassPropertyProcessor.php @@ -0,0 +1,139 @@ +getDeclaringClass()); + $dataProperty = $dataClass->properties->get($reflection->getName()); + + if ($dataProperty->hidden) { + return null; + } + + if ($dataProperty->outputMappedName) { + $property->name = new TypeScriptIdentifier($dataProperty->outputMappedName); + } + + if ($dataProperty->type->kind->isDataCollectable()) { + $property->type = $this->replaceCollectableTypeWithArray( + $reflection, + $property->type, + $dataProperty + ); + } + + if (! $property->type instanceof TypeScriptUnion) { + return $property; + } + + for ($i = 0; $i < count($property->type->types); $i++) { + $subType = $property->type->types[$i]; + + if ($subType instanceof TypeReference && $this->shouldHideReference($subType)) { + $property->isOptional = true; + + unset($property->type->types[$i]); + } + } + + $property->type->types = array_values($property->type->types); + + return $property; + } + + protected function replaceCollectableTypeWithArray( + ReflectionProperty $reflection, + TypeScriptNode $node, + DataProperty $dataProperty + ): TypeScriptNode { + if ($node instanceof TypeScriptUnion) { + foreach ($node->types as $i => $subNode) { + $node->types[$i] = $this->replaceCollectableTypeWithArray($reflection, $subNode, $dataProperty); + } + + return $node; + } + + if ( + $node instanceof TypeScriptGeneric + && $node->type instanceof TypeReference + && $this->findReplacementForDataCollectable($node->type) + ) { + $node->type = $this->findReplacementForDataCollectable($node->type); + + return $node; + } + + if ( + $node instanceof TypeReference + && $this->findReplacementForDataCollectable($node) + && $dataProperty->type->dataClass + ) { + return new TypeScriptGeneric( + $this->findReplacementForDataCollectable($node), + [new TypeReference(new ClassStringReference($dataProperty->type->dataClass))] + ); + } + + return $node; + } + + protected function findReplacementForDataCollectable( + TypeReference $reference + ): ?TypeScriptNode { + if (! $reference->reference instanceof ClassStringReference) { + return null; + } + + if ($reference->reference->classString === DataCollection::class) { + return new TypeScriptIdentifier('Array'); + } + + if ($reference->reference->classString === PaginatedDataCollection::class) { + return new TypeReference(new ClassStringReference(LengthAwarePaginator::class)); + } + + if ($reference->reference->classString === CursorPaginatedDataCollection::class) { + return new TypeReference(new ClassStringReference(CursorPaginator::class)); + } + + return null; + } + + protected function shouldHideReference( + TypeReference $reference + ): bool { + if (! $reference->reference instanceof ClassStringReference) { + return false; + } + + return is_a($reference->reference->classString, Lazy::class, true) + || is_a($reference->reference->classString, Optional::class, true); + } +} diff --git a/src/Support/Validation/RuleDenormalizer.php b/src/Support/Validation/RuleDenormalizer.php index 5280348f..9d3f61bc 100644 --- a/src/Support/Validation/RuleDenormalizer.php +++ b/src/Support/Validation/RuleDenormalizer.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Validation\Rule as RuleContract; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use Spatie\LaravelData\Attributes\Validation\CustomValidationAttribute; use Spatie\LaravelData\Attributes\Validation\ObjectValidationAttribute; use Spatie\LaravelData\Attributes\Validation\Rule; use Spatie\LaravelData\Attributes\Validation\StringValidationAttribute; @@ -38,6 +39,10 @@ public function execute(mixed $rule, ValidationPath $path): array return [$rule->getRule($path)]; } + if($rule instanceof CustomValidationAttribute) { + return Arr::wrap($rule->getRules($path)); + } + if ($rule instanceof Rule) { return $this->execute($rule->get(), $path); } diff --git a/tests/DataCollectionTest.php b/tests/DataCollectionTest.php index 4a1c04a4..97f7b21b 100644 --- a/tests/DataCollectionTest.php +++ b/tests/DataCollectionTest.php @@ -15,6 +15,7 @@ use Spatie\LaravelData\Tests\Fakes\DataCollections\CustomPaginatedDataCollection; use Spatie\LaravelData\Tests\Fakes\DefaultLazyData; use Spatie\LaravelData\Tests\Fakes\LazyData; +use Spatie\LaravelData\Tests\Fakes\MultiData; use Spatie\LaravelData\Tests\Fakes\SimpleData; use function Spatie\Snapshots\assertMatchesJsonSnapshot; @@ -118,7 +119,11 @@ expect($letters)->toMatchArray(['A', 'B', 'C', 'D']); }); -it('has array access', function (DataCollection $collection) { +it('has array access', function () { + $collection = SimpleData::collect([ + 'A', 'B', SimpleData::from('C'), SimpleData::from('D'), + ], DataCollection::class); + // Count expect($collection)->toHaveCount(4); @@ -128,9 +133,15 @@ expect(empty($collection[5]))->toBeTrue(); // Offset get - expect(SimpleData::from('A'))->toEqual($collection[0]); + $dataA = SimpleData::from('A'); + $dataA->getDataContext(); + + expect($collection[0])->toEqual($dataA); + + $dataD = SimpleData::from('D'); + $dataD->getDataContext(); - expect(SimpleData::from('D'))->toEqual($collection[3]); + expect($collection[3])->toEqual($dataD); if ($collection->items() instanceof AbstractPaginator || $collection->items() instanceof CursorPaginator) { return; @@ -140,17 +151,29 @@ $collection[2] = 'And now something completely different'; $collection[4] = 'E'; - expect( - SimpleData::from('And now something completely different') - ) - ->toEqual($collection[2]); - expect(SimpleData::from('E'))->toEqual($collection[4]); + $otherData = SimpleData::from('And now something completely different'); + $otherData->getDataContext(); + + expect($collection[2])->toEqual($otherData); + + $dataE = SimpleData::from('E'); + $dataE->getDataContext(); + + expect($collection[4])->toEqual($dataE); // Offset unset unset($collection[4]); expect($collection)->toHaveCount(4); -})->with('array-access-collections'); +}); + +it('has array access and will replicate partialtrees', function () { + $collection = MultiData::collect([ + new MultiData('first', 'second'), + ], DataCollection::class)->only('second'); + + expect($collection[0]->toArray())->toEqual(['second' => 'second']); +}); it('can dynamically include data based upon the request', function () { LazyData::$allowedIncludes = ['']; diff --git a/tests/DataTest.php b/tests/DataTest.php index 1cc6aa95..cd116d93 100644 --- a/tests/DataTest.php +++ b/tests/DataTest.php @@ -10,6 +10,7 @@ use Inertia\LazyProp; use Spatie\LaravelData\Attributes\Computed; use Spatie\LaravelData\Attributes\DataCollectionOf; +use Spatie\LaravelData\Attributes\Hidden; use Spatie\LaravelData\Attributes\MapOutputName; use Spatie\LaravelData\Attributes\Validation\Min; use Spatie\LaravelData\Attributes\WithCast; @@ -2463,6 +2464,63 @@ public function __construct( ->toThrow(CannotSetComputedValue::class); }); +it('can have a nullable computed value', function () { + $dataObject = new class ('', '') extends Data { + #[Computed] + public ?string $upper_name; + + public function __construct( + public ?string $name, + ) { + $this->upper_name = $name ? strtoupper($name) : null; + } + }; + + expect($dataObject::from(['name' => 'Ruben'])) + ->name->toBe('Ruben') + ->upper_name->toBe('RUBEN'); + + expect($dataObject::from(['name' => null])) + ->name->toBeNull() + ->upper_name->toBeNull(); + + expect($dataObject::validateAndCreate(['name' => 'Ruben'])) + ->name->toBe('Ruben') + ->upper_name->toBe('RUBEN'); + + expect($dataObject::validateAndCreate(['name' => null])) + ->name->toBeNull() + ->upper_name->toBeNull(); + + expect(fn () => $dataObject::from(['name' => 'Ruben', 'upper_name' => 'RUBEN'])) + ->toThrow(CannotSetComputedValue::class); + + expect(fn () => $dataObject::from(['name' => 'Ruben', 'upper_name' => null])) + ->name->toBeNull() + ->upper_name->toBeNull(); // Case conflicts with DefaultsPipe, ignoring it for now +}); + +it('can have a hidden value', function () { + $dataObject = new class ('', '') extends Data { + public function __construct( + public string $show, + #[Hidden] + public string $hidden, + ) { + } + }; + + expect($dataObject::from(['show' => 'Yes', 'hidden' => 'No'])) + ->show->toBe('Yes') + ->hidden->toBe('No'); + + expect($dataObject::validateAndCreate(['show' => 'Yes', 'hidden' => 'No'])) + ->show->toBe('Yes') + ->hidden->toBe('No'); + + expect($dataObject::from(['show' => 'Yes', 'hidden' => 'No'])->toArray())->toBe(['show' => 'Yes']); +}); + it('throws a readable exception message when the constructor fails', function ( array $data, string $message, diff --git a/tests/Datasets/Attributes/RulesDataset.php b/tests/Datasets/Attributes/RulesDataset.php index 5fe9e2fb..d91fc8f9 100644 --- a/tests/Datasets/Attributes/RulesDataset.php +++ b/tests/Datasets/Attributes/RulesDataset.php @@ -896,6 +896,10 @@ function requiredUnlessAttributes(): Generator attribute: new RequiredUnless('field', 'key', 'other'), expected: 'required_unless:field,key,other', ); + yield fixature( + attribute: new RequiredUnless('key', 'null'), + expected: 'required_unless:key,null', + ); } function requiredWithAttributes(): Generator diff --git a/tests/Datasets/DataCollection.php b/tests/Datasets/DataCollection.php index d73141b1..9b29f19f 100644 --- a/tests/Datasets/DataCollection.php +++ b/tests/Datasets/DataCollection.php @@ -3,20 +3,6 @@ use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\Tests\Fakes\SimpleData; -dataset('array-access-collections', function () { - yield "array" => [ - fn () => SimpleData::collect([ - 'A', 'B', SimpleData::from('C'), SimpleData::from('D'), - ], DataCollection::class), - ]; - - yield "collection" => [ - fn () => SimpleData::collect([ - 'A', 'B', SimpleData::from('C'), SimpleData::from('D'), - ], DataCollection::class), - ]; -}); - dataset('collection-operations', function () { yield [ 'operation' => 'filter', diff --git a/tests/Fakes/AbstractData/AbstractData.php b/tests/Fakes/AbstractData/AbstractData.php new file mode 100644 index 00000000..292784bd --- /dev/null +++ b/tests/Fakes/AbstractData/AbstractData.php @@ -0,0 +1,9 @@ + SimpleData::class, 'data_collection' => DataCollection::class.':'.SimpleData::class, + 'abstract_data' => AbstractData::class, ]; public $timestamps = false; @@ -24,6 +26,7 @@ public static function migrate() $blueprint->text('data')->nullable(); $blueprint->text('data_collection')->nullable(); + $blueprint->text('abstract_data')->nullable(); }); } } diff --git a/tests/Fakes/ValidationAttributes/PassThroughCustomValidationAttribute.php b/tests/Fakes/ValidationAttributes/PassThroughCustomValidationAttribute.php new file mode 100644 index 00000000..59cb2b6a --- /dev/null +++ b/tests/Fakes/ValidationAttributes/PassThroughCustomValidationAttribute.php @@ -0,0 +1,24 @@ +|object|string + */ + public function getRules(ValidationPath $path): array|object|string + { + return $this->rules; + } +} diff --git a/tests/Support/DataPropertyTest.php b/tests/Support/DataPropertyTest.php index 83716197..6e354a5a 100644 --- a/tests/Support/DataPropertyTest.php +++ b/tests/Support/DataPropertyTest.php @@ -2,6 +2,7 @@ use Spatie\LaravelData\Attributes\Computed; use Spatie\LaravelData\Attributes\DataCollectionOf; +use Spatie\LaravelData\Attributes\Hidden; use Spatie\LaravelData\Attributes\MapInputName; use Spatie\LaravelData\Attributes\MapOutputName; use Spatie\LaravelData\Attributes\WithCast; @@ -152,6 +153,21 @@ public function __construct( )->toBeTrue(); }); +it('can check if a property is hidden', function () { + expect( + resolveHelper(new class () { + public string $property; + })->hidden + )->toBeFalse(); + + expect( + resolveHelper(new class () { + #[Hidden] + public string $property; + })->hidden + )->toBeTrue(); +}); + 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/EloquentCasts/DataEloquentCastTest.php b/tests/Support/EloquentCasts/DataEloquentCastTest.php index d9ebd0ce..c28d4e2d 100644 --- a/tests/Support/EloquentCasts/DataEloquentCastTest.php +++ b/tests/Support/EloquentCasts/DataEloquentCastTest.php @@ -4,10 +4,11 @@ use function Pest\Laravel\assertDatabaseHas; +use Spatie\LaravelData\Support\DataConfig; +use Spatie\LaravelData\Tests\Fakes\AbstractData\AbstractDataA; +use Spatie\LaravelData\Tests\Fakes\AbstractData\AbstractDataB; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithCasts; - use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithDefaultCasts; - use Spatie\LaravelData\Tests\Fakes\SimpleData; use Spatie\LaravelData\Tests\Fakes\SimpleDataWithDefaultValue; @@ -79,3 +80,53 @@ ->toBeInstanceOf(SimpleDataWithDefaultValue::class) ->string->toEqual('default'); }); + +it('can use an abstract data class with multiple children', function () { + $abstractA = new AbstractDataA('A\A'); + $abstractB = new AbstractDataB('B\B'); + + $modelId = DummyModelWithCasts::create([ + 'abstract_data' => $abstractA, + ])->id; + + $model = DummyModelWithCasts::find($modelId); + + expect($model->abstract_data) + ->toBeInstanceOf(AbstractDataA::class) + ->a->toBe('A\A'); + + $model->abstract_data = $abstractB; + $model->save(); + + $model = DummyModelWithCasts::find($modelId); + + expect($model->abstract_data) + ->toBeInstanceOf(AbstractDataB::class) + ->b->toBe('B\B'); +}); + +it('can use an abstract data class with morph map', function () { + app(DataConfig::class)->enforceMorphMap([ + 'a' => AbstractDataA::class, + ]); + + $abstractA = new AbstractDataA('A\A'); + $abstractB = new AbstractDataB('B\B'); + + $modelA = DummyModelWithCasts::create([ + 'abstract_data' => $abstractA, + ]); + + $modelB = DummyModelWithCasts::create([ + 'abstract_data' => $abstractB, + ]); + + expect(json_decode($modelA->getRawOriginal('abstract_data'))->type)->toBe('a'); + expect(json_decode($modelB->getRawOriginal('abstract_data'))->type)->toBe(AbstractDataB::class); + + $loadedMorphedModel = DummyModelWithCasts::find($modelA->id); + + expect($loadedMorphedModel->abstract_data) + ->toBeInstanceOf(AbstractDataA::class) + ->a->toBe('A\A'); +}); diff --git a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php index df3f5bb8..14b3f76a 100644 --- a/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php +++ b/tests/Support/TypeScriptTransformer/DataTypeScriptTransformerTest.php @@ -14,21 +14,58 @@ use Spatie\LaravelData\Support\Lazy\ClosureLazy; use Spatie\LaravelData\Support\TypeScriptTransformer\DataTypeScriptTransformer; use Spatie\LaravelData\Tests\Fakes\SimpleData; - -use function Spatie\Snapshots\assertMatchesSnapshot as baseAssertMatchesSnapshot; - use Spatie\Snapshots\Driver; use Spatie\TypeScriptTransformer\Attributes\Optional as TypeScriptOptional; +use Spatie\TypeScriptTransformer\References\Reference; +use Spatie\TypeScriptTransformer\Support\TransformationContext; +use Spatie\TypeScriptTransformer\Support\WritingContext; +use Spatie\TypeScriptTransformer\Transformed\Transformed; +use Spatie\TypeScriptTransformer\Transformed\Untransformable; use Spatie\TypeScriptTransformer\TypeScriptTransformerConfig; +use function Spatie\Snapshots\assertMatchesSnapshot as baseAssertMatchesSnapshot; function assertMatchesSnapshot($actual, Driver $driver = null): void { baseAssertMatchesSnapshot(str_replace('\\r\\n', '\\n', $actual), $driver); } -it('can convert a data object to Typescript', function () { - $config = TypeScriptTransformerConfig::create(); +function transformData(Data $data): string +{ + $transformer = app(DataTypeScriptTransformer::class); + + $transformed = $transformer->transform( + new ReflectionClass($data), + new TransformationContext('SomeData', ['App', 'Data']) + ); + + return $transformed->typeScriptNode->write(new WritingContext( + fn (Reference $reference) => '{%'.$reference->humanFriendlyName().'%}' + )); +} + +it('will transform data objects', function () { + $transformer = app(DataTypeScriptTransformer::class); + + $transformed = $transformer->transform( + new ReflectionClass(SimpleData::class), + new TransformationContext('SomeData', ['App', 'Data']) + ); + + expect($transformed)->toBeInstanceOf(Transformed::class); + $someClass = new class { + + }; + + $transformed = $transformer->transform( + new ReflectionClass($someClass::class), + new TransformationContext('SomeData', ['App', 'Data']) + ); + + expect($transformed)->toBeInstanceOf(Untransformable::class); +}); + +it('can convert a data object to Typescript', function () { $data = new class (null, Optional::create(), 42, true, 'Hello world', 3.14, ['the', 'meaning', 'of', 'life'], Lazy::create(fn () => 'Lazy'), Lazy::closure(fn () => 'Lazy'), SimpleData::from('Simple data'), new DataCollection(SimpleData::class, []), new DataCollection(SimpleData::class, []), new DataCollection(SimpleData::class, [])) extends Data { public function __construct( public null|int $nullable, @@ -52,17 +89,10 @@ public function __construct( } }; - $transformer = new DataTypeScriptTransformer($config); - - $reflection = new ReflectionClass($data); - - expect($transformer->canTransform($reflection))->toBeTrue(); - assertMatchesSnapshot($transformer->transform($reflection, 'DataObject')->transformed); + assertMatchesSnapshot(transformData($data)); }); it('uses the correct types for data collection of attributes', function () { - $config = TypeScriptTransformerConfig::create(); - $collection = new DataCollection(SimpleData::class, []); $data = new class ($collection, $collection, $collection, $collection, $collection, $collection, $collection) extends Data { @@ -85,17 +115,10 @@ public function __construct( } }; - $transformer = new DataTypeScriptTransformer($config); - - $reflection = new ReflectionClass($data); - - expect($transformer->canTransform($reflection))->toBeTrue(); - assertMatchesSnapshot($transformer->transform($reflection, 'DataObject')->transformed); + assertMatchesSnapshot(transformData($data)); }); it('uses the correct types for paginated data collection for attributes ', function () { - $config = TypeScriptTransformerConfig::create(); - $collection = new PaginatedDataCollection(SimpleData::class, new LengthAwarePaginator([], 0, 15)); $data = new class ($collection, $collection, $collection, $collection, $collection, $collection, $collection) extends Data { @@ -118,17 +141,10 @@ public function __construct( } }; - $transformer = new DataTypeScriptTransformer($config); - - $reflection = new ReflectionClass($data); - - expect($transformer->canTransform($reflection))->toBeTrue(); - assertMatchesSnapshot($transformer->transform($reflection, 'DataObject')->transformed); + assertMatchesSnapshot(transformData($data)); }); it('uses the correct types for cursor paginated data collection of attributes', function () { - $config = TypeScriptTransformerConfig::create(); - $collection = new CursorPaginatedDataCollection(SimpleData::class, new CursorPaginator([], 15)); $data = new class ($collection, $collection, $collection, $collection, $collection, $collection, $collection) extends Data { @@ -151,17 +167,10 @@ public function __construct( } }; - $transformer = new DataTypeScriptTransformer($config); - - $reflection = new ReflectionClass($data); - - expect($transformer->canTransform($reflection))->toBeTrue(); - assertMatchesSnapshot($transformer->transform($reflection, 'DataObject')->transformed); + assertMatchesSnapshot(transformData($data)); }); it('outputs types with properties using their mapped name', function () { - $config = TypeScriptTransformerConfig::create(); - $data = new class ('Good job Ruben', 'Hi Ruben') extends Data { public function __construct( #[MapOutputName(SnakeCaseMapper::class)] @@ -172,16 +181,10 @@ public function __construct( } }; - $transformer = new DataTypeScriptTransformer($config); - $reflection = new ReflectionClass($data); - - expect($transformer->canTransform($reflection))->toBeTrue(); - assertMatchesSnapshot($transformer->transform($reflection, 'DataObject')->transformed); + assertMatchesSnapshot(transformData($data)); }); it('it respects a TypeScript property optional attribute', function () { - $config = TypeScriptTransformerConfig::create(); - $data = new class (10, 'Ruben') extends Data { public function __construct( #[TypeScriptOptional] @@ -191,24 +194,10 @@ public function __construct( } }; - $transformer = new DataTypeScriptTransformer($config); - $reflection = new ReflectionClass($data); - - $this->assertTrue($transformer->canTransform($reflection)); - $this->assertEquals( - <<transform($reflection, 'DataObject')->transformed - ); + assertMatchesSnapshot(transformData($data)); }); it('it respects a TypeScript class optional attribute', function () { - $config = TypeScriptTransformerConfig::create(); - #[TypeScriptOptional] class DummyTypeScriptOptionalClass extends Data { @@ -219,8 +208,6 @@ public function __construct( } } - ; - $transformer = new DataTypeScriptTransformer($config); $reflection = new ReflectionClass(DummyTypeScriptOptionalClass::class); @@ -234,4 +221,4 @@ public function __construct( TXT, $transformer->transform($reflection, 'DataObject')->transformed ); -}); +})->skip('Should be fixed in TS transformer'); diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index 99981d47..9917cf62 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Validator as ValidatorFacade; use Illuminate\Validation\Rules\Enum; use Illuminate\Validation\Rules\Exists as LaravelExists; +use Illuminate\Validation\Rules\In as LaravelIn; use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; @@ -20,6 +21,7 @@ use Spatie\LaravelData\Attributes\MapName; use Spatie\LaravelData\Attributes\Validation\ArrayType; use Spatie\LaravelData\Attributes\Validation\Bail; +use Spatie\LaravelData\Attributes\Validation\BooleanType; use Spatie\LaravelData\Attributes\Validation\Exists; use Spatie\LaravelData\Attributes\Validation\In; use Spatie\LaravelData\Attributes\Validation\IntegerType; @@ -30,6 +32,7 @@ use Spatie\LaravelData\Attributes\Validation\Required; use Spatie\LaravelData\Attributes\Validation\RequiredIf; use Spatie\LaravelData\Attributes\Validation\RequiredWith; +use Spatie\LaravelData\Attributes\Validation\RequiredWithout; use Spatie\LaravelData\Attributes\Validation\StringType; use Spatie\LaravelData\Attributes\Validation\Unique; use Spatie\LaravelData\Attributes\WithoutValidation; @@ -64,6 +67,7 @@ use Spatie\LaravelData\Tests\Fakes\SimpleDataWithExplicitValidationRuleAttributeData; use Spatie\LaravelData\Tests\Fakes\SimpleDataWithOverwrittenRules; use Spatie\LaravelData\Tests\Fakes\Support\FakeInjectable; +use Spatie\LaravelData\Tests\Fakes\ValidationAttributes\PassThroughCustomValidationAttribute; use Spatie\LaravelData\Tests\TestSupport\DataValidationAsserter; it('can validate a string', function () { @@ -473,7 +477,7 @@ public static function rules(Application $app): array ->assertOk(['nested' => null]) ->assertErrors(['nested' => ['string' => null]]) ->assertErrors(['nested' => []]) - ->assertRules([], payload: []) + ->assertRules(['nested' => ['nullable', 'array']], payload: []) ->assertRules([ 'nested' => ['nullable', 'array'], 'nested.string' => ['required', 'string'], @@ -490,7 +494,9 @@ public static function rules(Application $app): array ->assertErrors(['nested' => null]) ->assertErrors(['nested' => ['string' => null]]) ->assertErrors(['nested' => []]) - ->assertRules([], payload: []) + ->assertRules([ + 'nested' => ['sometimes', 'array'], + ], payload: []) ->assertRules([ 'nested' => ['sometimes', 'array'], 'nested.string' => ['required', 'string'], @@ -731,6 +737,40 @@ class TestDataWithRootReferenceFieldValidationAttribute extends Data ]); }); +it('will validate collection with explicit require', function () { + $dataClass = new class () extends Data { + #[Required] + #[DataCollectionOf(SimpleData::class)] + public DataCollection $collection; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk([ + 'collection' => [ + ['string' => 'Never Gonna'], + ['string' => 'Give You Up'], + ], + ]) + ->assertErrors(['collection' => []]) + ->assertErrors(['collection' => null]) + ->assertErrors([]) + ->assertErrors([ + 'collection' => [ + ['other_string' => 'Hello World'], + ], + ]) + ->assertRules([ + 'collection' => ['present', 'array', 'required'], + ]) + ->assertRules([ + 'collection' => ['present', 'array', 'required'], + 'collection.0.string' => ['required', 'string'], + ], [ + 'collection' => [[]], + ]); +}); + + it('will validate a collection with extra attributes', function () { $dataClass = new class () extends Data { #[DataCollectionOf(SimpleDataWithExplicitValidationRuleAttributeData::class)] @@ -784,8 +824,8 @@ class TestDataWithRootReferenceFieldValidationAttribute extends Data ['other_string' => 'Hello World'], ], ]) - ->assertRules([], payload: []) - ->assertRules([], payload: ['collection' => null]) + ->assertRules(['collection' => ['nullable', 'array']], payload: []) + ->assertRules(['collection' => ['nullable', 'array']], payload: ['collection' => null]) ->assertRules([ 'collection' => ['nullable', 'present', 'array'], 'collection.0.string' => ['required', 'string'], @@ -921,13 +961,13 @@ class CollectionClassTable extends Data ]) ->assertRules([ 'collection' => ['present', 'array'], - 'collection.0' => [], + 'collection.0.nested' => ['nullable', 'array'], ], [ 'collection' => [[]], ]) ->assertRules([ 'collection' => ['present', 'array'], - 'collection.0' => [], + 'collection.0.nested' => ['nullable', 'array'], ], [ 'collection' => [['nested' => null]], ]) @@ -961,7 +1001,7 @@ class CollectionClassC extends Data ]) ->assertRules([ 'collection' => ['present', 'array'], - 'collection.0' => [], + 'collection.0.nested' => ['sometimes', 'array'], ], [ 'collection' => [[]], ]) @@ -1021,6 +1061,64 @@ class CollectionClassC extends Data ); })->skip(version_compare(Application::VERSION, '9.0', '<'), 'Laravel too old'); +it('supports required without validation for optional collections', function () { + $dataClass = new class () extends Data { + #[RequiredWithout('someOtherData')] + #[DataCollectionOf(SimpleData::class)] + public DataCollection|Optional $someData; + + #[RequiredWithout('someData')] + #[DataCollectionOf(SimpleData::class)] + public DataCollection|Optional $someOtherData; + }; + + DataValidationAsserter::for($dataClass) + ->assertRules( + [ + 'someData' => [ + 'sometimes', + 'array', + 'required_without:someOtherData', + ], + 'someOtherData' => [ + 'sometimes', + 'array', + 'required_without:someData', + ], + ], + [] + ); +}); + +it('supports required without validation for nullable collections', function () { + $dataClass = new class () extends Data { + #[RequiredWithout('someOtherData')] + #[DataCollectionOf(SimpleData::class)] + public ?DataCollection $someData; + + #[RequiredWithout('someData')] + #[DataCollectionOf(SimpleData::class)] + public ?DataCollection $someOtherData; + }; + + DataValidationAsserter::for($dataClass) + ->assertRules( + [ + 'someData' => [ + 'nullable', + 'array', + 'required_without:someOtherData', + ], + 'someOtherData' => [ + 'nullable', + 'array', + 'required_without:someData', + ], + ], + [] + ); +}); + it('can nest data in classes inside collections using relative rule generation', function () { class CollectionClassK extends Data { @@ -2048,7 +2146,7 @@ public static function pipeline(): DataPipeline 'second' => ['required', 'string'], ]); - expect(NestedNullableData::getValidationRules(payload: []))->toEqual([]); + expect(NestedNullableData::getValidationRules(payload: []))->toEqual(['nested' => ['nullable', 'array']]); expect(NestedNullableData::getValidationRules(payload: ['nested' => []]))->toEqual([ 'nested' => ['nullable', 'array'], @@ -2081,6 +2179,7 @@ public static function rules(): array DataValidationAsserter::for(CircData::class) ->assertRules([ 'string' => ['required', 'string'], + 'ular' => ['nullable', 'array'], ]); DataValidationAsserter::for(CircData::class) @@ -2099,6 +2198,7 @@ public static function rules(): array 'ular.string' => ['required', 'string'], 'ular.circ' => ['nullable', 'array'], 'ular.circ.string' => ['required', 'string'], + 'ular.circ.ular' => ['nullable', 'array'], ], payload: [ 'string' => 'Hello World', 'ular' => [ @@ -2218,3 +2318,64 @@ public static function rules(ValidationContext $context): array 'array' => ['array', 'present'], ], []); }); + +it('supports custom validation attributes', function () { + $dataClass = new class () extends Data { + #[PassThroughCustomValidationAttribute(['url'])] + public string $url; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['url' => 'https://spatie.be']) + ->assertErrors(['url' => 'nowp']) + ->assertRules([ + 'url' => ['required', 'string', 'url'], + ], []); + + $dataClass = new class () extends Data { + #[PassThroughCustomValidationAttribute(['url', 'max:20'])] + public string $url; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['url' => 'https://spatie.be']) + ->assertErrors(['url' => 'nowp']) + ->assertErrors(['url' => 'https://rubenvanassche.com']) + ->assertRules([ + 'url' => ['required', 'string', 'url', 'max:20'], + ], []); + + $dataClass = new class () extends Data { + #[PassThroughCustomValidationAttribute([new LaravelIn(['a', 'b'])])] + public string $something; + }; + + + DataValidationAsserter::for($dataClass) + ->assertOk(['something' => 'a']) + ->assertOk(['something' => 'b']) + ->assertErrors(['something' => 'c']) + ->assertRules([ + 'something' => ['required', 'string', new LaravelIn(['a', 'b'])], + ], []); +}); + +it('can add a requiring rule on an attribute which will overwrite the optional type', function () { + $dataClass = new class () extends Data { + #[Required] + #[BooleanType] + public bool $success; + + #[RequiredIf('success', 'false')] + #[StringType] + public string $error = ''; + + #[RequiredIf('success', 'true')] + #[IntegerType] + public Optional|int $id; + }; + + DataValidationAsserter::for($dataClass) + ->assertOk(['success' => true, 'id' => 1]) + ->assertErrors(['success' => true]); +})->skip('V4: The rule inferrers need to be rewritten/removed for this, we need to first add attribute rules and then decide require stuff'); diff --git a/tests/__snapshots__/DataTypeScriptTransformerTest__it_can_convert_a_data_object_to_Typescript__1.txt b/tests/__snapshots__/DataTypeScriptTransformerTest__it_can_convert_a_data_object_to_Typescript__1.txt index 8e3d7003..c441e148 100644 --- a/tests/__snapshots__/DataTypeScriptTransformerTest__it_can_convert_a_data_object_to_Typescript__1.txt +++ b/tests/__snapshots__/DataTypeScriptTransformerTest__it_can_convert_a_data_object_to_Typescript__1.txt @@ -1,15 +1,15 @@ -{ -nullable: number | null; -undefineable?: number; -int: number; -bool: boolean; -string: string; -float: number; -array: Array; -lazy?: string; -closureLazy: string; -simpleData: {%Spatie\LaravelData\Tests\Fakes\SimpleData%}; -dataCollection: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; -dataCollectionAlternative: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; -dataCollectionWithAttribute: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; -} \ No newline at end of file +export type SomeData = { +nullable: number | null +undefineable?: number +int: number +bool: boolean +string: string +float: number +array: Array +lazy?: string +closureLazy?: string +simpleData: {%class Spatie\LaravelData\Tests\Fakes\SimpleData%} +dataCollection: Array<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> +dataCollectionAlternative: Array<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> +dataCollectionWithAttribute: Array<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> +}; diff --git a/tests/__snapshots__/DataTypeScriptTransformerTest__it_it_respects_a_TypeScript_property_optional_attribute__1.txt b/tests/__snapshots__/DataTypeScriptTransformerTest__it_it_respects_a_TypeScript_property_optional_attribute__1.txt new file mode 100644 index 00000000..78333aca --- /dev/null +++ b/tests/__snapshots__/DataTypeScriptTransformerTest__it_it_respects_a_TypeScript_property_optional_attribute__1.txt @@ -0,0 +1,4 @@ +export type SomeData = { +id?: number +name: string +}; diff --git a/tests/__snapshots__/DataTypeScriptTransformerTest__it_outputs_types_with_properties_using_their_mapped_name__1.txt b/tests/__snapshots__/DataTypeScriptTransformerTest__it_outputs_types_with_properties_using_their_mapped_name__1.txt index e5b50a34..4d0cd745 100644 --- a/tests/__snapshots__/DataTypeScriptTransformerTest__it_outputs_types_with_properties_using_their_mapped_name__1.txt +++ b/tests/__snapshots__/DataTypeScriptTransformerTest__it_outputs_types_with_properties_using_their_mapped_name__1.txt @@ -1,4 +1,4 @@ -{ -some_camel_case_property: string; -'some:non:standard:property': string; -} \ No newline at end of file +export type SomeData = { +some_camel_case_property: string +some:non:standard:property: string +}; diff --git a/tests/__snapshots__/DataTypeScriptTransformerTest__it_uses_the_correct_types_for_cursor_paginated_data_collection_of_attributes__1.txt b/tests/__snapshots__/DataTypeScriptTransformerTest__it_uses_the_correct_types_for_cursor_paginated_data_collection_of_attributes__1.txt index b26c509c..44d5fd37 100644 --- a/tests/__snapshots__/DataTypeScriptTransformerTest__it_uses_the_correct_types_for_cursor_paginated_data_collection_of_attributes__1.txt +++ b/tests/__snapshots__/DataTypeScriptTransformerTest__it_uses_the_correct_types_for_cursor_paginated_data_collection_of_attributes__1.txt @@ -1,9 +1,9 @@ -{ -collection: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array;meta:{path:string;per_page:number;next_cursor:string | null;next_cursor_url:string | null;prev_cursor:string | null;prev_cursor_url:string | null;};}; -collectionWithNull: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array;meta:{path:string;per_page:number;next_cursor:string | null;next_cursor_url:string | null;prev_cursor:string | null;prev_cursor_url:string | null;};} | null; -collectionWithNullable: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array;meta:{path:string;per_page:number;next_cursor:string | null;next_cursor_url:string | null;prev_cursor:string | null;prev_cursor_url:string | null;};} | null; -optionalCollection?: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array;meta:{path:string;per_page:number;next_cursor:string | null;next_cursor_url:string | null;prev_cursor:string | null;prev_cursor_url:string | null;};}; -optionalCollectionWithNullable?: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array;meta:{path:string;per_page:number;next_cursor:string | null;next_cursor_url:string | null;prev_cursor:string | null;prev_cursor_url:string | null;};} | null; -lazyCollection?: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array;meta:{path:string;per_page:number;next_cursor:string | null;next_cursor_url:string | null;prev_cursor:string | null;prev_cursor_url:string | null;};}; -lazyCollectionWithNullable?: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array;meta:{path:string;per_page:number;next_cursor:string | null;next_cursor_url:string | null;prev_cursor:string | null;prev_cursor_url:string | null;};} | null; -} \ No newline at end of file +export type SomeData = { +collection: {%class Illuminate\Pagination\CursorPaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> +collectionWithNull: {%class Illuminate\Pagination\CursorPaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null +collectionWithNullable: {%class Illuminate\Pagination\CursorPaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null +optionalCollection?: {%class Illuminate\Pagination\CursorPaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> +optionalCollectionWithNullable?: {%class Illuminate\Pagination\CursorPaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null +lazyCollection?: {%class Illuminate\Pagination\CursorPaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> +lazyCollectionWithNullable?: {%class Illuminate\Pagination\CursorPaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null +}; diff --git a/tests/__snapshots__/DataTypeScriptTransformerTest__it_uses_the_correct_types_for_data_collection_of_attributes__1.txt b/tests/__snapshots__/DataTypeScriptTransformerTest__it_uses_the_correct_types_for_data_collection_of_attributes__1.txt index 2e46c020..ae9089ca 100644 --- a/tests/__snapshots__/DataTypeScriptTransformerTest__it_uses_the_correct_types_for_data_collection_of_attributes__1.txt +++ b/tests/__snapshots__/DataTypeScriptTransformerTest__it_uses_the_correct_types_for_data_collection_of_attributes__1.txt @@ -1,9 +1,9 @@ -{ -collection: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; -collectionWithNull: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null; -collectionWithNullable: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null; -optionalCollection?: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; -optionalCollectionWithNullable?: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null; -lazyCollection?: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>; -lazyCollectionWithNullable?: Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null; -} \ No newline at end of file +export type SomeData = { +collection: Array<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> +collectionWithNull: Array<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null +collectionWithNullable: Array<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null +optionalCollection?: Array<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> +optionalCollectionWithNullable?: Array<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null +lazyCollection?: Array<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> +lazyCollectionWithNullable?: Array<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null +}; diff --git a/tests/__snapshots__/DataTypeScriptTransformerTest__it_uses_the_correct_types_for_paginated_data_collection_for_attributes___1.txt b/tests/__snapshots__/DataTypeScriptTransformerTest__it_uses_the_correct_types_for_paginated_data_collection_for_attributes___1.txt index fbeb4758..199b0305 100644 --- a/tests/__snapshots__/DataTypeScriptTransformerTest__it_uses_the_correct_types_for_paginated_data_collection_for_attributes___1.txt +++ b/tests/__snapshots__/DataTypeScriptTransformerTest__it_uses_the_correct_types_for_paginated_data_collection_for_attributes___1.txt @@ -1,9 +1,9 @@ -{ -collection: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array<{url:string | null;label:string;active:boolean;}>;meta:{current_page:number;first_page_url:string;from:number | null;last_page:number;last_page_url:string;next_page_url:string | null;path:string;per_page:number;prev_page_url:string | null;to:number | null;total:number;};}; -collectionWithNull: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array<{url:string | null;label:string;active:boolean;}>;meta:{current_page:number;first_page_url:string;from:number | null;last_page:number;last_page_url:string;next_page_url:string | null;path:string;per_page:number;prev_page_url:string | null;to:number | null;total:number;};} | null; -collectionWithNullable: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array<{url:string | null;label:string;active:boolean;}>;meta:{current_page:number;first_page_url:string;from:number | null;last_page:number;last_page_url:string;next_page_url:string | null;path:string;per_page:number;prev_page_url:string | null;to:number | null;total:number;};} | null; -optionalCollection?: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array<{url:string | null;label:string;active:boolean;}>;meta:{current_page:number;first_page_url:string;from:number | null;last_page:number;last_page_url:string;next_page_url:string | null;path:string;per_page:number;prev_page_url:string | null;to:number | null;total:number;};}; -optionalCollectionWithNullable?: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array<{url:string | null;label:string;active:boolean;}>;meta:{current_page:number;first_page_url:string;from:number | null;last_page:number;last_page_url:string;next_page_url:string | null;path:string;per_page:number;prev_page_url:string | null;to:number | null;total:number;};} | null; -lazyCollection?: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array<{url:string | null;label:string;active:boolean;}>;meta:{current_page:number;first_page_url:string;from:number | null;last_page:number;last_page_url:string;next_page_url:string | null;path:string;per_page:number;prev_page_url:string | null;to:number | null;total:number;};}; -lazyCollectionWithNullable?: {data:Array<{%Spatie\LaravelData\Tests\Fakes\SimpleData%}>;links:Array<{url:string | null;label:string;active:boolean;}>;meta:{current_page:number;first_page_url:string;from:number | null;last_page:number;last_page_url:string;next_page_url:string | null;path:string;per_page:number;prev_page_url:string | null;to:number | null;total:number;};} | null; -} \ No newline at end of file +export type SomeData = { +collection: {%class Illuminate\Pagination\LengthAwarePaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> +collectionWithNull: {%class Illuminate\Pagination\LengthAwarePaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null +collectionWithNullable: {%class Illuminate\Pagination\LengthAwarePaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null +optionalCollection?: {%class Illuminate\Pagination\LengthAwarePaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> +optionalCollectionWithNullable?: {%class Illuminate\Pagination\LengthAwarePaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null +lazyCollection?: {%class Illuminate\Pagination\LengthAwarePaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> +lazyCollectionWithNullable?: {%class Illuminate\Pagination\LengthAwarePaginator%}<{%class Spatie\LaravelData\Tests\Fakes\SimpleData%}> | null +};