Większość skomplikowanych aplikacji opiera się o relacyjne bazy danych i wymaga ciągłego wydobywania powiązanych ze sobą informacji. Railsy ułatwiają te operacje, udostępniając moduł ActiveRecord, dysponujący wieloma przydatnymi metodami. Żeby jednak w pełni wykorzystywać ich możliwości – należy je dobrze rozumieć i znać ich zastosowania.
Warto na przykład wiedzieć czym różni się joins od includes.

Kiedy którym się posłużyć? Najkrótsza, ale najprawdziwsza odpowiedź brzmi:

optimal use cases – wszystko zależy od tego jak i jakie dane chcemy wykorzystać

Błędnym zastosowaniem ryzykujemy

Ale zacznijmy od początku – skąd tytuł nawiązujący do zachłanności czy lenistwa?

Eager loading VS Lazy loading

Niezależnie od frameworka, czy nawet języka programowania, jeżeli posługujemy się ORM (Object-relational mapping ) możemy mówić o dwóch koncepcjach:

Eager loading

Lazy loading

Na pierwszy rzut oka lepszym rozwiązaniem wydaje się lazy loading – po co ładować wszystko na raz, dopóki tego nie potrzebujemy?

I wtedy na scenę wchodzi performance, ale o nim – już w kontekście Railsów.

JOINS

Zacznijmy od joins, bo jest z nim nieco łatwiejsza sprawa.

@users = User.joins(:clients).where('clients.country = ?', 'Poland')
 User.joins(:clients).where('clients.country = ?', 'Poland').each { |u| u.clients.map(&:country) }; nil
  User Load (...ms)  SELECT "users".* FROM "users" INNER JOIN "clients" ON "clients"."user_id" = "users"."id" WHERE (clients.country = 'Polska')
  Client Load (7.2ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  Client Load (0.6ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  Client Load (0.5ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id",  ...]]
  Client Load (0.5ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  Client Load (0.5ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  Client Load (0.5ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  Client Load (0.4ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  Client Load (0.4ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  Client Load (0.4ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  Client Load (0.7ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  Client Load (1.9ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  Client Load (2.2ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  Client Load (0.6ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  Client Load (0.5ms)  SELECT "clients".* FROM "clients" WHERE "clients"."user_id" = $1  [["user_id", ...]]
  ....
User.joins(:clients).where('clients.country = ?', 'Poland').to_sql
 => "SELECT \"users\".* FROM \"users\" INNER JOIN \"clients\" ON \"clients\".\"user_id\" = \"users\".\"id\" WHERE (clients.country = 'Poland')"
=> generalnie joins będziemy używali do przefiltrowania wyników kiedy nie korzystamy z rekordów relacji

INCLUDES

…jak można się domyślić stoi po drugiej stronie barykady

@users = User.includes(:clients)
User.includes(:clients).where('clients.country = ?', 'Poland').references(:clients).each { |u| u.clients.map(&:country) }; nil
  SQL (...ms)  SELECT "users"."id" AS t0_r0, "users"."imie" AS t0_r1, "users"."nazwisko" AS t0_r2, "users"."email" AS t0_r3, "users"."nazwa_uzytkownika" AS t0_r4, "users"."haslo" AS t0_r5, (...) "clients"."id" AS t1_r0, "clients"."user_id" AS t1_r1, "clients"."nazwa_firmy" AS t1_r2, "clients"."ulica" AS t1_r3, "clients"."miejscowosc" AS t1_r4, "clients"."kod_pocztowy" AS t1_r5, "clients"."nip" AS t1_r6, "clients"."numer_telefonu" AS t1_r7, (...) "clients"."touched_at" AS t1_r28 FROM "users" LEFT OUTER JOIN "clients" ON "clients"."user_id" = "users"."id" WHERE (clients.country = 'Polska')
User.includes(:clients).eager_load(:clients)

INCLUDES + WHERE

Dlaczego dostało własną sekcję?
Otóż okazuje się, że może przysporzyć kłopotów. Próba przefiltrowania użytkowników przy wykorzystaniu includes

@users = User.includes(:clients).where('clients.country = ?', 'Poland')

ActiveRecord::StatementInvalid: PG::UndefinedTable: ERROR:  missing FROM-clause entry for table "clients"....

…może nie skończyć się najlepiej.

Aby móc ‘postawić warunki’, wykorzystując includes możemy skorzystać z references Należy jednak pamiętać, że includes jako argument przyjmuje nazwę relacji określonej w modelu, references zaś – nazwę tabeli

@users = User.includes(:clients).where('clients.country = ?', 'Poland').references(:clients)

…lub też skorzystać z innej składni, której efektem będzie takie samo zapytanie SQL

@users = User.includes(:clients).where(clients: { country: 'Poland'})

TL;DR

JOINS INCLUDES
Kiedy warto?
Filtrowanie wyników na podstawie powiązanych
Kiedy warto?
Wykorzystanie danych rekordów powiązanych
lazy loading eager loading
tylko parent data parent + relationships data
domyślnie: INNER JOIN domyślnie: LEFT OUTER JOIN
ryzyko N+1 queries przy odwołaniu do danych powiązanych należy uważać przy filtrowaniu (grozi error)
ładuje za pomocą single query albo separate queries

Ciekawostka do refleksji

Na początku wspomniałam o ryzyku uzyskania innych danych niż się spodziewamy w zależności od wykorzystanego zapytania.
Dlatego zostawiam do samodzielnego przemyślenia skąd takie różnice w – na pozór – identycznych zapytaniach:

2.4.0 :001 > User.includes(:clients).where(clients: { email: nil }).count
(3.0ms)  SELECT COUNT(DISTINCT "users"."id") FROM "users" LEFT OUTER JOIN "clients" ON "clients"."user_id" = "users"."id" WHERE "clients"."email" IS NULL
 => 158
2.4.0 :002 > User.joins(:clients).where(clients: { email: nil }).count
   (1.0ms)  SELECT COUNT(*) FROM "users" INNER JOIN "clients" ON "clients"."user_id" = "users"."id" WHERE "clients"."email" IS NULL
 => 47
2.4.0 :003 > User.includes(:clients).where('clients.email = ?', nil).references(:clients).count
   (1.0ms)  SELECT COUNT(DISTINCT "users"."id") FROM "users" LEFT OUTER JOIN "clients" ON "clients"."user_id" = "users"."id" WHERE (clients.email = NULL)
 => 0