From ee513f693f8ab8f915dc26cae079d7b2e5999a65 Mon Sep 17 00:00:00 2001 From: Ruben Van Assche Date: Fri, 3 May 2024 17:01:04 +0200 Subject: [PATCH] Add support for loading model relations and fix performance problems on mutated attributes --- docs/as-a-data-transfer-object/factories.md | 2 +- .../model-to-data-object.md | 255 ++++++++++++++++++ src/Attributes/LoadRelation.php | 10 + src/Normalizers/ModelNormalizer.php | 47 +--- src/Normalizers/Normalized/Normalized.php | 10 + .../Normalized/NormalizedModel.php | 86 ++++++ .../Normalized/UnknownProperty.php | 18 ++ src/Normalizers/Normalizer.php | 4 +- src/Support/ResolvedDataPipeline.php | 25 ++ tests/Fakes/Models/FakeModel.php | 11 + tests/Normalizers/ModelNormalizerTest.php | 129 ++++++++- 11 files changed, 551 insertions(+), 46 deletions(-) create mode 100644 docs/as-a-data-transfer-object/model-to-data-object.md create mode 100644 src/Attributes/LoadRelation.php create mode 100644 src/Normalizers/Normalized/Normalized.php create mode 100644 src/Normalizers/Normalized/NormalizedModel.php create mode 100644 src/Normalizers/Normalized/UnknownProperty.php diff --git a/docs/as-a-data-transfer-object/factories.md b/docs/as-a-data-transfer-object/factories.md index abb7accdb..9d10772ac 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: 10 +weight: 11 --- It is possible to automatically create data objects in all sorts of forms with this package. Sometimes a little bit more diff --git a/docs/as-a-data-transfer-object/model-to-data-object.md b/docs/as-a-data-transfer-object/model-to-data-object.md new file mode 100644 index 000000000..b93d77117 --- /dev/null +++ b/docs/as-a-data-transfer-object/model-to-data-object.md @@ -0,0 +1,255 @@ +--- +title: From a model +weight: 10 +--- + +It is possible to create a data object from a model, let's say we have the following model: + +```php +class Artist extends Model +{ + +} +``` + +It has the following columns in the database: + +- id +- first_name +- last_name +- created_at +- updated_at + +We can create a data object from this model like this: + +```php +class ArtistData extends Data +{ + public int $id; + public string $first_name; + public string $last_name; + public CarbonImmutable $created_at; + public CarbonImmutable $updated_at; +} +``` + +We now can create a data object from the model like this: + +```php +$artist = ArtistData::from(Artist::find(1)); +``` + +## Casts + +A model can have casts, these casts will be called before a data object is created. Let's extend the model: + +```php +class Artist extends Model +{ + public function casts(): array + { + return [ + 'properties' => 'array' + ]; + } +} +``` + +Within the database the new column will be stored as a JSON string, but in the data object we can just use the array +type: + +```php +class ArtistData extends Data +{ + public int $id; + public string $first_name; + public string $last_name; + public array $properties; + public CarbonImmutable $created_at; + public CarbonImmutable $updated_at; +} +``` + +## Attributes & Accessors + +Laravel allows you to define attributes on a model, these will be called before a data object is created. Let's extend +the model: + +```php +class Artist extends Model +{ + public function getFullNameAttribute(): string + { + return $this->first_name . ' ' . $this->last_name; + } +} +``` + +We now can use the attribute in the data object: + +```php +class ArtistData extends Data +{ + public int $id; + public string $full_name; + public CarbonImmutable $created_at; + public CarbonImmutable $updated_at; +} +``` + +Remember: we need to use the snake_case version of the attribute in the data object since that's how it is stored in the +model. Read on for a more elegant solution when you want to use camelCase property names in your data object. + +It is also possible to define accessors on a model which are the successor of the attributes: + +```php +class Artist extends Model +{ + public function getFullName(): Attribute + { + return Attribute::get(fn () => "{$this->first_name} {$this->last_name}"); + } +} +``` + +With the same data object we created earlier we can now use the accessor. + +## Mapping property names + +Sometimes you want to use camelCase property names in your data object, but the model uses snake_case. You can use +an `MapInputName` to map the property names: + +```php +use Spatie\LaravelData\Attributes\MapInputName; +use Spatie\LaravelData\Mappers\SnakeCaseMapper; + +class ArtistData extends Data +{ + public int $id; + #[MapInputName(SnakeCaseMapper::class)] + public string $fullName; + public CarbonImmutable $created_at; + public CarbonImmutable $updated_at; +} +``` + +An even more elegant solution would be to map every property within the data object: + +```php +#[MapInputName(SnakeCaseMapper::class)] +class ArtistData extends Data +{ + public int $id; + public string $fullName; + public CarbonImmutable $createAt; + public CarbonImmutable $updatedAt; +} +``` + +## Relations + +Let's create a new model: + +```php +class Song extends Model +{ + public function artist(): BelongsTo + { + return $this->belongsTo(Artist::class); + } +} +``` + +Which has the following columns in the database: + +- id +- artist_id +- title + +We update our previous model as such: + +```php +class Artist extends Model +{ + public function songs(): HasMany + { + return $this->hasMany(Song::class); + } +} +``` + +We can now create a data object like this: + +```php +class SongData extends Data +{ + public int $id; + public string $title; +} +``` + +And update our previous data object like this: + +```php +class ArtistData extends Data +{ + public int $id; + /** @var array */ + public array $songs; + public CarbonImmutable $created_at; + public CarbonImmutable $updated_at; +} +``` + +We can now create a data object with the relations like this: + +```php +$artist = ArtistData::from(Artist::with('songs')->find(1)); +``` + +When you're not loading the relations in advance, `null` will be returned for the relation. + +It is however possible to load the relation on the fly by adding the `LoadRelation` attribute to the property: + +```php +class ArtistData extends Data +{ + public int $id; + /** @var array */ + #[LoadRelation] + public array $songs; + public CarbonImmutable $created_at; + public CarbonImmutable $updated_at; +} +``` + +Now the data object with relations can be created like this: + +```php +$artist = ArtistData::from(Artist::find(1)); +``` + +We even eager-load the relation for performance, neat! + +### Be careful with automatic loading of relations + +Let's update the `SongData` class like this: + +```php +class SongData extends Data +{ + public int $id; + public string $title; + #[LoadRelation] + public ArtistData $artist; +} +``` + +When we now create a data object like this: + +```php +$song = SongData::from(Song::find(1)); +``` + +We'll end up in an infinite loop, since the `SongData` class will try to load the `ArtistData` class, which will try to +load the `SongData` class, and so on. diff --git a/src/Attributes/LoadRelation.php b/src/Attributes/LoadRelation.php new file mode 100644 index 000000000..7b5f0d337 --- /dev/null +++ b/src/Attributes/LoadRelation.php @@ -0,0 +1,10 @@ +toArray(); - - foreach ($value->getDates() as $key) { - if (isset($properties[$key])) { - $properties[$key] = $value->getAttribute($key); - } - } - - foreach ($value->getCasts() as $key => $cast) { - if ($this->isDateCast($cast)) { - if (isset($properties[$key])) { - $properties[$key] = $value->getAttribute($key); - } - } - } - - foreach ($value->getRelations() as $key => $relation) { - $key = $value::$snakeAttributes ? Str::snake($key) : $key; - - if (isset($properties[$key])) { - $properties[$key] = $relation; - } - } - - foreach ($value->getMutatedAttributes() as $key) { - $properties[$key] = $value->getAttribute($key); - } - - return $properties; - } - - protected function isDateCast(string $cast): bool - { - return in_array($cast, [ - 'date', - 'datetime', - 'immutable_date', - 'immutable_datetime', - 'custom_datetime', - 'immutable_custom_datetime', - ]); + return new NormalizedModel($value); } } diff --git a/src/Normalizers/Normalized/Normalized.php b/src/Normalizers/Normalized/Normalized.php new file mode 100644 index 000000000..d77f35cd1 --- /dev/null +++ b/src/Normalizers/Normalized/Normalized.php @@ -0,0 +1,10 @@ +initialize($this->model); + } + + public function getProperty(string $name, DataProperty $dataProperty): mixed + { + $propertyName = $this->model::$snakeAttributes ? Str::snake($name) : $name; + + return $this->properties[$propertyName] ?? $this->fetchNewProperty($propertyName, $dataProperty); + } + + protected function initialize(Model $model): void + { + $this->properties = $model->toArray(); + + foreach ($model->getDates() as $key) { + if (isset($this->properties[$key])) { + $this->properties[$key] = $model->getAttribute($key); + } + } + + foreach ($model->getCasts() as $key => $cast) { + if ($this->isDateCast($cast)) { + if (isset($this->properties[$key])) { + $this->properties[$key] = $model->getAttribute($key); + } + } + } + + foreach ($model->getRelations() as $key => $relation) { + $key = $model::$snakeAttributes ? Str::snake($key) : $key; + + if (isset($this->properties[$key])) { + $this->properties[$key] = $relation; + } + } + } + + protected function isDateCast(string $cast): bool + { + return in_array($cast, [ + 'date', + 'datetime', + 'immutable_date', + 'immutable_datetime', + 'custom_datetime', + 'immutable_custom_datetime', + ]); + } + + protected function fetchNewProperty(string $name, DataProperty $dataProperty): mixed + { + if (in_array($name, $this->model->getMutatedAttributes())) { + return $this->properties[$name] = $this->model->getAttribute($name); + } + + if (! $dataProperty->attributes->contains(fn (object $attribute) => $attribute::class === LoadRelation::class)) { + return UnknownProperty::create(); + } + + $studlyName = Str::studly($name); + + if (! method_exists($this->model, $studlyName)) { + return UnknownProperty::create(); + } + + $this->model->load($studlyName); + + return $this->properties[$name] = $this->model->{$studlyName}; + } +} diff --git a/src/Normalizers/Normalized/UnknownProperty.php b/src/Normalizers/Normalized/UnknownProperty.php new file mode 100644 index 000000000..e49dcec70 --- /dev/null +++ b/src/Normalizers/Normalized/UnknownProperty.php @@ -0,0 +1,18 @@ +dataClass->name, $value); } + if (! is_array($properties)) { + $properties = $this->transformNormalizedToArray($properties); + } + $properties = ($this->dataClass->name)::prepareForPipeline($properties); foreach ($this->pipes as $pipe) { @@ -44,4 +50,23 @@ public function execute(mixed $value, CreationContext $creationContext): array return $properties; } + + protected function transformNormalizedToArray(Normalized $normalized): array + { + $properties = []; + + foreach ($this->dataClass->properties as $property) { + $name = $property->inputMappedName ?? $property->name; + + $value = $normalized->getProperty($name, $property); + + if ($value === UnknownProperty::create()) { + continue; + } + + $properties[$name] = $value; + } + + return $properties; + } } diff --git a/tests/Fakes/Models/FakeModel.php b/tests/Fakes/Models/FakeModel.php index 01386168c..095bb7a7d 100644 --- a/tests/Fakes/Models/FakeModel.php +++ b/tests/Fakes/Models/FakeModel.php @@ -2,6 +2,7 @@ namespace Spatie\LaravelData\Tests\Fakes\Models; +use Exception; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -31,6 +32,16 @@ public function getOldAccessorAttribute() return "old_accessor_{$this->string}"; } + public function getPerformanceHeavyAttribute() + { + throw new Exception('This attribute should not be called'); + } + + public function performanceHeavyAccessor(): Attribute + { + return Attribute::get(fn () => throw new Exception('This accessor should not be called')); + } + protected static function newFactory() { return FakeModelFactory::new(); diff --git a/tests/Normalizers/ModelNormalizerTest.php b/tests/Normalizers/ModelNormalizerTest.php index 6bd08a854..df701e045 100644 --- a/tests/Normalizers/ModelNormalizerTest.php +++ b/tests/Normalizers/ModelNormalizerTest.php @@ -1,6 +1,14 @@ date->toEqual($data->date); }); -it('can get a data object with nesting from model and relations', function () { +it('can get a data object with nesting from model and relations when loaded', function () { $model = FakeModel::factory()->create(); $nestedModelA = FakeNestedModel::factory()->for($model)->create(); @@ -47,3 +55,122 @@ ->accessor->toEqual($data->accessor) ->old_accessor->toEqual($data->old_accessor); }); + +it('it will only call model accessors when required', function () { + $dataClass = new class () extends Data { + public string $accessor; + + public string $old_accessor; + }; + + $dataClass::from(FakeModel::factory()->create()); + + $dataClass = new class () extends Data { + public string $performance_heavy; + }; + + expect(fn () => $dataClass::from(FakeModel::factory()->create()))->toThrow( + Exception::class, + 'This attribute should not be called' + ); + + $dataClass = new class () extends Data { + public string $performance_heavy_accessor; + }; + + expect(fn () => $dataClass::from(FakeModel::factory()->create()))->toThrow( + Exception::class, + 'This accessor should not be called' + ); +}); + +it('will return null for non-existing properties', function () { + $dataClass = new class () extends Data { + public ?string $non_existing_property; + }; + + $data = $dataClass::from(FakeModel::factory()->create()); + + expect($data->non_existing_property)->toBeNull(); +}); + +it('can load relations on a model when required and the LoadRelation attribute is set', function () { + $model = FakeModel::factory()->create(); + + FakeNestedModel::factory()->for($model)->create(); + FakeNestedModel::factory()->for($model)->create(); + + $dataClass = new class () extends Data { + #[LoadRelation, DataCollectionOf(FakeNestedModelData::class)] + public array $fake_nested_models; + }; + + DB::enableQueryLog(); + + $data = $dataClass::from($model); + + $queryLog = DB::getQueryLog(); + + expect($data->fake_nested_models) + ->toHaveCount(2) + ->each->toBeInstanceOf(FakeNestedModelData::class); + expect($queryLog)->toHaveCount(1); +}); + +it('will not automatically load relation when the LoadRelation attribute is not set', function () { + $model = FakeModel::factory()->create(); + + FakeNestedModel::factory()->for($model)->create(); + FakeNestedModel::factory()->for($model)->create(); + + $dataClass = new class () extends Data { + #[DataCollectionOf(FakeNestedModelData::class)] + public array|Optional $fake_nested_models; + }; + + DB::enableQueryLog(); + + $data = $dataClass::from($model); + + $queryLog = DB::getQueryLog(); + + expect($data->fake_nested_models)->toBeInstanceOf(Optional::class); + expect($queryLog)->toHaveCount(0); + + $dataClass = new class () extends Data { + public array|null $fake_nested_models = null; + }; + + DB::enableQueryLog(); + + $data = $dataClass::from($model); + + $queryLog = DB::getQueryLog(); + + expect($data->fake_nested_models)->toBeNull(); + expect($queryLog)->toHaveCount(0); +}); + +it('can use mappers to map the names', function () { + $model = FakeModel::factory()->create(); + + $nestedModelA = FakeNestedModel::factory()->for($model)->create(); + $nestedModelB = FakeNestedModel::factory()->for($model)->create(); + + $dataClass = new class () extends Data { + #[DataCollectionOf(FakeNestedModelData::class), MapInputName(SnakeCaseMapper::class)] + public array|Optional $fakeNestedModels; + + #[MapInputName(SnakeCaseMapper::class)] + public string $oldAccessor; + }; + + $data = $dataClass::from($model->load('fakeNestedModels')); + + expect($data->fakeNestedModels) + ->toHaveCount(2) + ->each() + ->toBeInstanceOf(FakeNestedModelData::class); + + expect($data)->oldAccessor->toEqual($model->old_accessor); +});