Modelli dinamici e relazioni polimorfiche cover image

Modelli dinamici e relazioni polimorfiche

alberto • December 13, 2020

tutorial eloquent

Le relazioni polimorfiche sono uno speciale tipo di relazione che permette di creare una associazione tra modelli anche se di classi differenti.

L'esempio classico che spesso viene utilizzato per approfondire questo concetto mette in relazione 3 diversi modelli:

Entrambi i modelli "padri", Post e Video, possono avere una lista di commenti. Grazie alle relazioni polimorfiche potremmo utilizzare un'unica classe Commento che possa essere associata ad un Post o ad un Commento a seconda delle esigenze.

L'alternativa a questo approccio sarebbe quella di creare due classi ad hoc, VideoCommento e PostCommento con le rispettive relazioni hasMany: soluzione funzionale ma sicuramente poco gestibile e poco scalabile.

Due tipologie di relazioni

Le relazioni polimorfiche possono essere declinate in una tra due diverse implementazioni:

Seppure le relazioni uno a uno e uno a molti sono due relazioni differenti, le analizzeremo insieme dato che l'unica differenza è il numero di record ritornati invocando la relazione (rispettivamente uno e molti), mentre da un punto implementativo vengono configurate allo stesso modo.

Relazioni uno a uno / uno a molti

Come anticipato, queste tipologie di relazioni non necessitano tabelle addizionali: per gestire il polimorfismo della relazione è necessaria solamente una nuova colonna in più nella tabella che contiene la chiave esterna.

Riprendendo l'esempio di prima questa potrebbe essere una struttura tipica:

#tabella posts
    - id
    - message

#tabella videos
    - id
    - youtube_embed_url

#tabella comments
    - id
    - content
    - commentable_id
    - commentable_type

La particolarità di questa struttura è rappresentata dalle colonne commentable_id e commentable_type.

Queste colonne permettono di introdurre il concetto di Commentable ovvero una astrazione per identificare oggetti che siano commentabili e che, in questo contesto, sono sia oggetti Post che oggetti Video.

Entrando in profondità, questo significa che:

I modelli

Per configurare la relazione dobbiamo modificare i modelli in questo modo:

//app/Models/Post.php
class Post extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

//app/Models/Video.php
class Video extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

//app/Models/Comment.php
class Comment extends Model
{
    public function commentable()
    {
        return $this->morphTo();
    }
}

Sporchiamoci le mani

Una volta configurato il tutto possiamo iniziare a giocare con la nostra nuovissima relazione:

$comment = new Comment;
$comment->content = "Questo articolo è proprio una figata";
$post = Post::find(1);
$post->comments()->save($comment);

$comment = new Comment;
$comment->content = "Questo video è proprio una figata";
$video = Video::find(1);
$video->comments()->save($comment);

Uno snippet di questo tipo genererà due insert all'interno della tabella comments:

id, content, commentable_id, commentable_type
1   ...      1               App/Models/Post
2   ...      1               App/Models/Video

E andando a richiamare la relazione otterremo gli oggetti appena inseriti a database:

$post = Post::find(1);
$post->comments->count(); //1
$post->comments->first()->id; //1

$video = Video::find(1);
$video->comments->count(); //1
$video->comments->first()->id; //2

get_class(Comment::find(1)->commentable) //App\Models\Post
get_class(Comment::find(2)->commentable) //App\Models\Video

Magia di Eloquent

Se volessimo indagare internamente ad Eloquent in che modo avviene la magia, noteremmo che la query per ottenere i commenti a partire da un oggetto commentable è simile ad una semplice query di tipo one to one / one to many ma con una aggiunta particolare legata al commentable_type.

L'accesso quindi alla relazione comments genererà una query simile a:

SELECT * FROM comments WHERE commentable_id = 1 AND commentable_type = "App\Models\Post";

Questa nuova condizione viene appunto aggiunta automaticamente da Eloquent per essere certo che l'id sia effettivamente correlato ad una istanza di Post.

MorphMany e MorphOne

Gli esempi precedenti hanno fatto riferimento al metodo morphMany che permette di creare una relazione uno a molti. Nel caso volessimo una relazione uno ad uno basterebbe utilizzare il metodo morphOne all'interno dei modelli Post e Video. In tal caso il nome del metodo dovrebbe essere definito al singolare dato che il metodo ritornerà una singola istanza di Comment.

Relazioni molti a molti e tabella pivot

Nel caso di relazioni molti a molti, non ci sono grandi novità. Il concetto di oggetto "polimorfico" (che nel precedente esempio era Commentable) continua ad esistere. L'unica differenza è il modo in cui esso viene persistito a database.

Se nel caso di relazioni semplici il riferimento era nella tabella che conteneva la foreign key della relazione, in questo caso viene salvato all'interno della tabella pivot, tabella che esisterebbe comunque essendo necessaria anche per una relazione base.

Uno scenario tipico dove questo tipo di relazione viene utilizzato riguarda i "tag", ovvero la possibilità di descrivere i contenuti di un portale con delle keywork specifiche e ricercabili. In un contesto non particolarmente banale, i contenuti di un portale sono possono essere di natura diversa: Post, Video, Foto. Se volessimo quindi introdurre i tag in modalità polimorfica, potremmo utilizzare una struttura dati simile a questa:

#tabella posts
    - id
    - message

#tabella videos
    - id
    - youtube_embed_url

#tabella photos
    - id
    - image_path

#tabella tags
    - id
    - name

#tabella taggables
    - id
    - tag_id
    - taggable_id
    - taggable_type

Abbiamo quindi le 4 tabelle principali relative agli oggetti più una tabella pivot che ha una referenza forte con un tag (colonna tag_id) e una referenza polimorfica con un contenuto appunto taggabile.

Relazioni complesse: i modelli

Anche in questo caso, l'implementazione di questa relazione all'interno dei modelli è triviale:

//app/Models/Comment.php
class Comment extends Model
{
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

//app/Models/Post.php
class Post extends Model
{
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

//app/Models/Photo.php
class Photo extends Model
{
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

//app/Models/Tag.php
class Tag extends Model
{

    public function posts()
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }

    public function videos()
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }

    public function photos()
    {
        return $this->morphedByMany(Photo::class, 'taggable');
    }
}

Personalizziamo le nostre relazioni

Come abbiamo visto negli esempi precedenti, il comportamento di default di Laravel è quello di inserire all'interno delle colonne *_type il nome completo della classe cosi da essere indipendente nella creazione dei modelli una volta recuperati da database.

Sebbene questo comportamento non abbia particolari problemi, potrebbe sussistere la necessità di cambiare questo comportamento.

Grazie al metodo statico morphMap della classe Relation, possiamo sovrascrivere questo comportamento. Basta quindi aggiungere, all'interno di AppServiceProvider questo frammento:

use Illuminate\Database\Eloquent\Relations\Relation;

Relation::morphMap([
    'post' => 'App\Models\Post',
    'video' => 'App\Models\Video',
]);

In questo caso, Eloquent salverà all'interno del database le stringhe post e video al posto del riferimento completo della classe.

Attenzione però che questo comportamento non è retrocompatibile e che se ci sono già dei record a database, questi rimarranno orfani.

Concludendo

Le relazioni polimorfiche sono un ottimo strumento all'interno del kit fornito da Eloquent. Permettono di risparmiare parecchio codice e parecchie tabelle quando un concetto viene utilizzato da più modelli.

Occhio però a non abusarne. Il contesto di utilizzo dovrebbe essere comunque perimetrato a relazioni che davvero sono polimorfiche. Se non sussiste questa condizione, utilizzare i morph potrebbe rivelarsi una scelta sbagliata.