#181 Include vs Joins
- Download:
- source codeProject Files in Zip (99.3 KB)
- mp4Full Size H.264 Video (19.8 MB)
- m4vSmaller H.264 Video (13.3 MB)
- webmFull Size VP8 Video (30.6 MB)
- ogvFull Size Theora Video (30.2 MB)
Il metodo find di ActiveRecord accetta diverse opzioni. Due di queste, che esamineremo in questo episodio, sono include
e joins
. Si confondono spesso fra di loro dal momento che svolgono compiti piuttosto simili, ma ci sono dei particolari casi in cui dobbiamo necessariamente sceglierne uno al posto dell’altro.
Per spiegare le differenze fra include
e joins
, useremo un’applicazione Rails in cui gli utenti possono scrivere commenti. I due modelli sono in relazione fra di loro con una associazione standard has_many
/ belongs_to
:
class User < ActiveRecord::Base has_many :comments end
class Comment < ActiveRecord::Base belongs_to :user end
L’applicazione ha una pagina che mostra tutti i commenti che sono stati scritti. Per ciascuno viene anche mostrato il nome dell’autore, unitamente anche al testo “(admin)”, nel caso in cui l’utente sia un amministratore.
Poniamo di voler modificare la pagina, per mostrare solamente i commenti scritti da utenti amministratori. Per fare ciò, dobbiamo cambiare la find all’interno della action index
del CommentsController
che attualmente si presenta così:
def index @comments = Comment.all(:order => "comments.created_at desc") end
Noi vogliamo invece filtrare i commenti su di un attributo presente nella tabella users, per cui dobbiamo mettere in join tale tabella in una singola query con la find dei nostri Comments
. Però ci viene un dubbio: dobbiamo usare la joins
o la include
per farlo? Per capirlo, confronteremo i due tipi di query in console.
Prima proviamo a usare joins
, aggiungendo una condizione, affinchè siano restituiti solo gli utenti amministratori.
>> c = Comment.all(:joins => :user, :conditions => { :users => { :admin => true } }) Comment Load (1.2ms) SELECT "comments".* FROM "comments" INNER JOIN "users" ON "users".id = "comments".user_id WHERE ("users"."admin" = 't') +----+-----------------------+---------+-----------------------+-----------------------+ | id | content | user_id | created_at | updated_at | +----+-----------------------+---------+-----------------------+-----------------------+ | 3 | Some people, when ... | 1 | 2009-09-28 19:00:3... | 2009-09-28 19:00:3... | | 5 | Write the code as ... | 2 | 2009-09-28 19:44:0... | 2009-09-28 19:44:0... | | 6 | Walking on water a... | 1 | 2009-09-28 19:46:2... | 2009-09-28 19:46:2... | | 8 | It should be noted... | 2 | 2009-09-28 19:49:3... | 2009-09-28 19:49:3... | +----+-----------------------+---------+-----------------------+-----------------------+ 4 rows in set
La joins esegue una singola query che restituisce solamente gli attributi dalla tabella comments. Se proviamo a prendere un riferimento all’utente del primo commento, vien fatta un’ulteriore chiamata al database, dal momento che non è stato recuperato alcun attributo dalla tabella users con la prima query:
>> c.first.user User Load (0.3ms) SELECT * FROM "users" WHERE ("users"."id" = 1) +----+--------+-------+-------------------------+-------------------------+ | id | name | admin | created_at | updated_at | +----+--------+-------+-------------------------+-------------------------+ | 1 | Andrea | true | 2009-09-28 18:51:53 UTC | 2009-09-28 18:51:53 UTC | +----+--------+-------+-------------------------+-------------------------+ 1 row in set
Ora proviamo a riottenere lo stesso risultato, questa volta usando però la include
al posto della joins
:
>> c = Comment.all(:include => :user, :conditions => { :users => { :admin => true } }) Comment Load Including Associations (0.7ms) SELECT "comments"."id" AS t0_r0, "comments"."content" AS t0_r1, "comments"."user_id" AS t0_r2, "comments"."created_at" AS t0_r3, "comments"."updated_at" AS t0_r4, "users"."id" AS t1_r0, "users"."name" AS t1_r1, "users"."admin" AS t1_r2, "users"."created_at" AS t1_r3, "users"."updated_at" AS t1_r4 FROM "comments" LEFT OUTER JOIN "users" ON "users".id = "comments".user_id WHERE ("users"."admin" = 't') +----+-----------------------+---------+-----------------------+-----------------------+ | id | content | user_id | created_at | updated_at | +----+-----------------------+---------+-----------------------+-----------------------+ | 3 | Some people, when ... | 1 | 2009-09-28 19:00:3... | 2009-09-28 19:00:3... | | 5 | Write the code as ... | 2 | 2009-09-28 19:44:0... | 2009-09-28 19:44:0... | | 6 | Walking on water a... | 1 | 2009-09-28 19:46:2... | 2009-09-28 19:46:2... | | 8 | It should be noted... | 2 | 2009-09-28 19:49:3... | 2009-09-28 19:49:3... | +----+-----------------------+---------+-----------------------+-----------------------+ 4 rows in set
Questa volta viene eseguita una query SQL più complicata e vengono prese colonne sia dalla tabella comments, sia dalla tabella users. I modelli User
associati sono salvati in memoria, per cui quando si prende il riferimento all’utente del primo commento, questa volta non avvene nessuna ulteriore chiamata al database:
>> c.first.user +----+--------+-------+-------------------------+-------------------------+ | id | name | admin | created_at | updated_at | +----+--------+-------+-------------------------+-------------------------+ | 1 | Andrea | true | 2009-09-28 18:51:53 UTC | 2009-09-28 18:51:53 UTC | +----+--------+-------+-------------------------+-------------------------+ 1 row in set
Modifica alla pagina dei commenti
Ora che abbiamo un po’ capito le differenze fra la include
e la joins
, ci chiediamo quali fra queste due si debba usare per la nostra pagina dei commenti. La domanda che ci dobbiamo porre in realtà è “useremo degli attributi del modello in relazione?” Nel nostro caso la risposta è “sì”, dal momento che stiamo mostrando il nome dell’utente su ciascun commento. Significa anche che vogliamo ottenere gli utenti nello stesso momento in cui recuperiamo i commenti, per cui dobbiamo usare include
in questo caso.
Tornando al nostro CommentsController
, cambiamo la action index
affinchè recuperi gli utenti insieme ai commenti:
def index @comments = Comment.all(:include => :user, :conditions => { :users => { :admin => true} }, :order => "comments.created_at desc") end
La find
appare un tantino complessa ora, per cui probabilmente sposteremmo alcune parti in un named scope se questa fosse un’applicazione da destinarsi alla produzione, ma siccome non lo è, lasciamo tutto così com’è e non deviamo dai nostri intenti didattici.
Se invece non avessimo mostrato i nomi degli utenti a fianco dei commenti? Vediamo questo caso. Per prima cosa dobbiamo togliere le parti che mostrano il nome dell’utente (e se è amministratore) dal partial comment
:
<div class="comment"> <%= simple_format comment.content %> <span style="text-decoration: line-through;"><p class="author"></span> <span style="text-decoration: line-through;"><%= h comment.user.name %></span> <span style="text-decoration: line-through;"><% if comment.user.admin? %>(admin)<% end %></span> <span style="text-decoration: line-through;"></p></span> <p class="actions"> <%= link_to "edit", edit_comment_path(comment) %> | <%= link_to "destroy", comment, :method => :delete, :confirm => "Are you sure?" %> </p> </div>
Ricaricando la pagina ora, i nomi degli utenti sono effettivamente scomparsi:
Non stiamo più mostrando alcuna informazione sull’utente associato al commento nella pagina, per cui la include ora è piuttosto inefficiente, dal momento che recuperiamo inutilmente tutte le informazioni sull’utente. In questo caso, la scelta corretta è quella di usare la joins
, in questo modo si evita di caricare inutilmente dal database informazioni che non servono. Tutto ciò che dobbiamo fare, dunque, è di sostituire la include
con la joins
all’interno della find:
def index @comments = Comment.all(:joins => :user, :conditions => { :users => { :admin => true} }, :order => "comments.created_at desc") end
In questo modo utilizziamo la tabella users esclusivamente per filtrare e rendiamo la pagina dei commenti molto più performante sia come tempi di risposta, sia come occupazione di memoria.
Un altro esempio
Ridiamo un’occhiata all’SQL prodotto quando abbiamo eseguito da console la nostra find con include
:
SELECT "comments"."id" AS t0_r0, "comments"."content" AS t0_r1, "comments"."user_id" AS t0_r2, "comments"."created_at" AS t0_r3, "comments"."updated_at" AS t0_r4, "users"."id" AS t1_r0, "users"."name" AS t1_r1, "users"."admin" AS t1_r2, "users"."created_at" AS t1_r3, "users"."updated_at" AS t1_r4 FROM "comments" LEFT OUTER JOIN "users" ON "users".id = "comments".user_id WHERE ("users"."admin" = 't')
Questa istruzione è piuttosto complicata dal momento che prende ciascuna colonna sia dalla tabella comments, sia dalla tabella users, rinominandole tutte. Significa che la include
non lavora realmente con la opzione select
, poichè non abbiamo alcun controllo su come la prima parte della istruzione di SELECT sia generata. Se occorre poter definire i campi della SELECT, dovremmo usare la joins
sulla include
.
In che casi potrebbe tornarci utile tutto ciò? Nella nostra pagina degli utenti, abbiamo un elenco di utenti e, per ciascuno di essi, un contatore che rappresenta il numero di commenti scritti da ciascun utente:
Nel file della vista per tale pagina, il numero dei commenti è ottenuto usando la seguente linea di codice:
<%= pluralize user.comments.count, "comment" %>
Questo codice esegue una query separata per ciascun utente in lista, il che non è certo l’ideale. Sarebbe meglio se potessimo recuperare il numero di commenti nello stesso istante in cui recuperiamo il resto delle informazioni sugli utenti.
Lo possiamo fare, usando la joins
con l’opzione select
. Usiamo nuovamente la console per dimostrare come fare.
Questa volta recuperiamo tutti gli utenti e usiamo la joins
per associarli alla tabella comments. Usiamo l’opzione select
per restringere le colonne alle sole della tabella users e per ottenere un conteggio dei commenti, e anche l’opzione group
che fornisce ActiveRecord, per raggruppare i risultati per id
di ciascun utente:
>> User.all(:joins => :comments, :select => "users.*, count(comments.id) as comments_count", :group => "users.id")
Lanciando questa, otteniamo i dettagli per tutti gli utenti e un contatore del numero di commenti che ciascuno ha fatto:
User Load (1.3ms) SELECT users.*, count(comments.id) as comments_count FROM "users" INNER JOIN "comments" ON comments.user_id = users.id GROUP BY users.id +----+--------+-------+------------------+------------------+----------------+ | id | name | admin | created_at | updated_at | comments_count | +----+--------+-------+------------------+------------------+----------------+ | 1 | Andrea | true | 2009-09-28 18... | 2009-09-28 18... | 2 | | 2 | Susan | true | 2009-09-28 18... | 2009-09-28 18... | 2 | | 3 | Paul | false | 2009-09-28 18... | 2009-09-28 18... | 3 | | 4 | John | false | 2009-09-28 18... | 2009-09-28 18... | 1 | +----+--------+-------+------------------+------------------+----------------+ 4 rows in set
Ora che siamo in grado di recuperare gli utenti e il numero di commenti che ciascuno di essi ha scritto in un’unica query, possiamo cambiare la pagina index per usare la nuova query. Dobbiamo fare due piccole modifiche. Nel controller, sostituiamo User.all
con la nostra nuova find
:
def index @users = User.all(:joins => :comments, :select => "users.*, count(comments.id) as comments_count", :group => "users.id") end
e nella vista index possiamo usare il campo comments_count
per mostrare il numero di commenti che ciascun utente ha scritto, sostituendo il codice comments.count
che causava l’esecuzione di una nuova query su ciascun utente in pagina:
<%= pluralize user.comments_count, "comment" %>
La pagina degli utenti apparirà assolutamente identica a prima, al ricaricamento, ma sarà molto più efficiente nel modo in cui accede al database, poichè farà un’unica query.
Un altro utilizzo per la joins
Concludiamo l’episodio mostrandovi un altro buon candidato all’uso della joins
sulla include
. Sotto ci sono i modelli User
e Comment
che abbiamo già usato, più altri due nuovi: Group
e Membership
:
class Group < ActiveRecord::Base has_many :memberships has_many :users, :through => :memberships end class Membership < ActiveRecord::Base belongs_to :user belongs_to :group end class User < ActiveRecord::Base has_many :memberships has_many :groups, :through => :memberships has_many :comments end class Comment < ActiveRecord::Base belongs_to :user end
In questo scenario User
e Group
sono in relazione molti-a-molti fra loro mediante la associativa Membership
. Vogliamo mostrare tutti i commenti che sono stati scritti dagli utenti che appartengono ad un gruppo specifico. Idealmente vorremmo che ci fosse una sorta di associazione fra Group
e Comment
, forse qualcosa del genere:
class Group < ActiveRecord::Base has_many :membership has_many :users, :through => :memberships has_many :comments, :through => :users end
Ruby non supporta le associazioni has_many :through
annidate tipo questa, però, per cui dobbiamo trovare un’altra soluzione. Fortunatamente possiamo usare anche qui la joins
.
Ecco come appare la pagina che stiamo costruendo, la vista show
del GroupController
. Abbiamo una lista dei menbri del gruppo, ma non abbiamo ancora aggiunto i loro commenti:
Torniamo nuovamente sulla console per cercare di trovare il codice che ci mostri i commenti. Innanzitutto otteniamo il nostro gruppo:
>> g = Group.first Group Load (0.4ms) SELECT * FROM "groups" LIMIT 1 +----+------------------+-------------------------+-------------------------+ | id | name | created_at | updated_at | +----+------------------+-------------------------+-------------------------+ | 1 | Musician's Guild | 2009-10-01 20:09:11 UTC | 2009-10-01 20:09:11 UTC | +----+------------------+-------------------------+-------------------------+ 1 row in set
E poi useremo la joins
per ottenere i commenti per i membri del gruppo. Dobbiamo unire le tabelle utenti e membership, per cui possiamo unire entrambe le associazioni User
e Memberships
. Poi possiamo aggiungere delle condizioni e restringere l’appartenenza a un gruppo il cui group_id
è lo stesso id
del nostro gruppo:
>> Comment.all(:joins => { :user => :memberships }, :conditions => { :memberships => { :group_id => g.id } } ) Comment Load (0.7ms) SELECT "comments".* FROM "comments" INNER JOIN "users" ON "users".id = "comments".user_id INNER JOIN "memberships" ON memberships.user_id = users.id WHERE ("memberships"."group_id" = 1) +----+--------------------+---------+--------------------+--------------------+ | id | content | user_id | created_at | updated_at | +----+--------------------+---------+--------------------+--------------------+ | 1 | I have always w... | 3 | 2009-09-28 18:5... | 2009-09-28 18:5... | | 3 | Some people, wh... | 1 | 2009-09-28 19:0... | 2009-09-28 19:0... | | 4 | Java is to Java... | 3 | 2009-09-28 19:0... | 2009-09-28 19:0... | | 5 | Write the code ... | 2 | 2009-09-28 19:4... | 2009-09-28 19:4... | | 6 | Walking on wate... | 1 | 2009-09-28 19:4... | 2009-09-28 19:4... | | 7 | Never trust a c... | 3 | 2009-09-28 19:4... | 2009-09-28 19:4... | | 8 | It should be no... | 2 | 2009-09-28 19:4... | 2009-09-28 19:4... | +----+--------------------+---------+--------------------+--------------------+ 7 rows in set
Il lancio della query mostra tutti i commenti degli utenti del nostro gruppo, per cui possiamo usarla per chiudere definitivamente la nostra pagina dei gruppi. La useremo in un nuovo metodo comments all’interno del nostro modello Group
:
class Group < ActiveRecord::Base has_many :memberships has_many :users, :through => :memberships def comments Comment.all(:joins => { :user => :memberships}, :conditions => { :memberships => { :group_id => id } } ) end end
Dobbiamo ancora mostrare i commenti nella nostra pagina, per cui dobbiamo aggiornare la vista. Abbiamo già un partial comment
, per cui tutto quello che dobbiamo fare è di renderizzare i commenti:
<h2>Comments</h2> <%= render @group.comments %>
Se ricarichiamo la pagina ora, vedremo i commenti sotto l’elenco degli utenti:
Riguardando il metodo comments del modello Group
, pare che funzioni se sostituiamo la joins
con la include
: queste due opzioni sono spesso intercambiabili. Si tenga presente, comunque, che l’utilizzo della include
in questo punto causa il caricamento dell’utente e delle membership associate in memoria, che in questo caso non vogliamo.
Il nostro metodo comments sta quasi creando un’altra associazione e in questi casi potremmo usare scoped
al posto di all
. Tutto ciò si comporterà quasi come un named scope, ma generato dinamicamente. Il vantaggio è che possiamo concatenare altri scope ad esso per limitare ulteriormente e modificare il limite della find.
Se avete trovato utile questo episodio e desiderate avere ulteriori informazioni sull’ottimizzazione delle query ActiveRecord, Ryan Bates ha prodotto una serie di screencasts denominata “Everyday Active Record” che approfondisce le aree che abbiamo trattato quest’oggi.