Skip to content

Commit

Permalink
Add support for loading model relations and fix performance problems …
Browse files Browse the repository at this point in the history
…on mutated attributes
  • Loading branch information
rubenvanassche committed May 3, 2024
1 parent 7e737bf commit ee513f6
Show file tree
Hide file tree
Showing 11 changed files with 551 additions and 46 deletions.
2 changes: 1 addition & 1 deletion docs/as-a-data-transfer-object/factories.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
255 changes: 255 additions & 0 deletions docs/as-a-data-transfer-object/model-to-data-object.md
Original file line number Diff line number Diff line change
@@ -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<SongData> */
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<SongData> */
#[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.
10 changes: 10 additions & 0 deletions src/Attributes/LoadRelation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Spatie\LaravelData\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
class LoadRelation
{
}
47 changes: 4 additions & 43 deletions src/Normalizers/ModelNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,56 +3,17 @@
namespace Spatie\LaravelData\Normalizers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Spatie\LaravelData\Normalizers\Normalized\Normalized;
use Spatie\LaravelData\Normalizers\Normalized\NormalizedModel;

class ModelNormalizer implements Normalizer
{
public function normalize(mixed $value): ?array
public function normalize(mixed $value): null|array|Normalized
{
if (! $value instanceof Model) {
return null;
}

$properties = $value->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);
}
}
10 changes: 10 additions & 0 deletions src/Normalizers/Normalized/Normalized.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Spatie\LaravelData\Normalizers\Normalized;

use Spatie\LaravelData\Support\DataProperty;

interface Normalized
{
public function getProperty(string $name, DataProperty $dataProperty): mixed;
}
Loading

0 comments on commit ee513f6

Please sign in to comment.