
Has One Of Many
alberto • December 16, 2021
focusSono un fan di Eloquent. È sicuramente il componente di Laravel che mi piace di piú ed ogni novità introdotto nell'ORM mi appassiona sempre un sacco.
Una delle ultimi aggiornamenti ha introdotto una funzionalità che ritengo super utile e soprattutto super fatta bene. Perchè ormai siamo tutti capaci a fare una cosa. La cosa difficile è farla bene.
Many o not many?
L'argomento del giorno è una nuova tipologia di relazione, denominata Has One Of Many ovvero la possibilità di estrarre da una relazione multipla Has Many solo un particolare record, sfruttando in pieno tutte le funzionalità standard offerte dall'ORM.
Un esempio classico di questa funzionalità potrebbe essere la relazione, all'interno di un E-Commerce, tra utenti e ordini. Supponiamo quindi che in un backoffice dedicato all'amministratore volessimo mostrare l'elenco degli utenti con il riferimento al prezzo dell'primo ordine fatto. In questo caso si parla appunto di Has One Of Many perchè la relazione è singola, dato che ciascun utente ha solamente un solo primo ordine, ma che questo ordine viene preso da una struttura dati multipla.
Per capirci meglio disegniamo la struttura dati che potrebbe avere questo ipotetico portale:
#tabella users
- id
- email
- name
#tabella orders
- id
- user_id
- price
- created_at
Un classico errore
Quello che talvolta siamo orientati a fare è un modello di questo tipo:
class User
{
public function orders()
{
return $this->hasMany(Order::class);
}
public function firstOrder()
{
return $this->hasOne(Order::class)->orderBy('created_at');
}
}
Abbiamo creato due relazioni: orders
che è una semplice HasMany standard e firstOrder
che è una HasOne che utilizza la stessa tabella della HasMany (orders
) andando peró ad impostare una clausola orderBy
. Grazie al fatto che la relazione HasOne imposta automaticamente una limit 1
, questo codice funziona perfettamente.
Guardiamo in dettaglio le query che vengono lanciate:
User::first()->orders
produce
select * from "orders" where "orders"."user_id" = 1 and "orders"."user_id" is not null
mentre
User::first()->lastOrder
produce
select * from "orders" where "orders"."user_id" = 1 and "orders"."user_id" is not null order by "created_at" limit 1
Cosí, a prima vista, potrebbe sembrare tutto ok. Ed infatti fintanto che usiamo le relazioni in questo modo lo è.
Peró non siamo tutti pigri
Gli esempi sopra riportati usavano peró le relazioni nella modalità lazy, ovvero accedendo al database appena ne avevamo bisogno senza precaricare nulla in anticipo. Solitamente questo approccio è fenomenale in alcune situazioni ma pessimo in altre. E il nostro esempio descritto sopra è uno di questi ultimi casi. Avendo infatti una lista di utenti, usando il metodo lazy, cadiamo nel problema noto come quello delle N+1 query.
Sfruttiamo quindi la modalità eager per precaricare tutti i dati e analizziamo nel dettaglio quindi quello che succede:
User::with('orders')->get()
produce queste due query:
select * from "users"
select * from "orders" where "orders"."user_id" in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
mentre
User::with('lastOrder')->get();
produce
select * from "users"
select * from "orders" where "orders"."user_id" in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) order by "created_at"
Anche se a prima vista tutto sembra regolare (la eager utilizza una clausola where in con l'elenco di tutti gli id), l'ultima query non è proprio ottimale. Essa infatti carica tutti gli Orders, esattamente come la eager precedente - dove peró abbiamo esplicitamente chiesto di avere tutti gli ordini.
Seppur la cosa possa sembrare non problematica su database piccoli, al crescere della mole di dati questa soluzione diventa devastante sia per il database che per l'applicativo.
Has one of many
Proviamo quindi a riscrivere la logica con il nuovo strumento Eloquent:
class User
{
public function orders()
{
return $this->hasMany(Order::class);
}
public function firstOrder()
{
return $this->hasOne(Order::class)->oldestOfMany()
}
}
Il metodo oldestOfMany
, oltre ad essere squisitamente leggibile ed eloquente, permette di eseguire una query, che seppur a prima vista sia piú complessa, ottimizza radicalmente l'accesso al database.
Riproviamo quindi con il nostro lavoro certosino di debug:
User::with('lastOrder')->get();
produce
select * from "users"
select *
from "orders"
inner join (
select MIN(id) as id
from "orders"
group by orders.user_id
) as latest_orders
on latest_orders.id = orders.id
La query incriminata è diventata quindi una nested query che permette, in prima battuta, di ottenere per ogni utente (grazie al group by orders.user_id
) l'id del primo ordine (MIN(created_at)
) e, in seconda battuta, di joinare questo dato con la stessa tabella orders
per recuperare il record puntuale.
Sicuramente un pelo piú complesso, ma sicuramente molto piú efficente!
Ma non finisce qua
Oltre ovviamente al metodo oldestOfMany
analizzato in precedenza, Eloquent offre queste possibilità:
oldestOfMany
prende il primo record, eventualmente specificando una colonna diversa da "id" come primo argomento
public function firstOrder()
{
return $this->hasOne(Order::class)->oldestOfMany()
}
latestOfMany
prende l'ultimo record, eventualmente specificando una colonna diversa da "id" come primo argomento
public function latestOrder()
{
return $this->hasOne(Order::class)->latestOfMany()
}
ofMany
versione piú astratta delle precedenti che accetta oltre al nome della colonna, anche la funzione di aggregazione (max
o min
)
public function mostExpensiveOrder()
{
return $this->hasOne(Order::class)->ofMany('price', 'max')
}
ofMany
offre anche la possibilità di passare una lista di condizioni qualora si volesse raffinare ancora la ricerca.
Che dire? FI-GA-TA.
P.S. se l'argomento vi ha affascinato, vi consiglio di approfondire ulteriormente (sí, ulteriormente!) nella descrizione della PR pubbliata su GitHub.