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

  • performance (słynne N+1 queries)
  • czasem niepoprawnymi danymi (niezgodnymi z naszymi oczekiwaniami)
  • czy nawet zderzeniem z errorem

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

  • …tłumaczone na język polski jako ładowanie zachłanne, ładowanie chciwe
  • related, child objects are loaded automatically with its parent object
  • jest zachłanne, ponieważ polega na ładowaniu od razu wszystkiego co jest możliwe, maksimum informacji na temat obiektów powiązanych z naszym bazowym
  • załadujemy zarówno dane z tabeli parent jak i dane wynikające z relationships

Lazy loading

  • …tłumaczone jako ładowanie leniwe
  • related objects are not loaded until it is required
  • w odróżnieniu od eager loading - tutaj ładujemy minimum informacji, sięgając po dodatkowe dopiero, kiedy są potrzebne, kiedy jawnie po nie sięgamy (wykonane zostanie wtedy dodatkowe zapytanie)
  • załadujemy jedynie dane z tabeli parent

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')
  • wiążemy z nim lazy loading
  • ładuje do pamięci dane dotyczące użytkowników na podstawie relacji z klientami
  • nie ładuje natomiast danych dotyczących samych klientów
  • próba wydobycia danych dot. klientów - będzie wiązała się z dodatkowym zapytaniem (#smutnyPerformance #N+1queries)
 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", ...]]
  ....
  • warto zwrócić uwagę, że posługuje się domyślnie INNER JOIN-em
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)
  • tutaj mówimy o eager loading
  • obie tabele ładowane są do pamięci
  • w ten sposób korzystanie z danych klientów - nie wywoła dodatkowego zapytania
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')
  • warto wiedzieć, że dane mogą być ładowane na dwa sposoby:
    • jako osobne zapytania (separate queries)
    • przy pomocy jednego zapytania (domyślnie: w oparciu o LEFT OUTER JOIN)
  • o tym który ‘wybrać’ decyduje nie performance ale postać zapytania
  • aby wymusić single query (i tym samym LEFT OUTER JOIN) - można wykorzystać eager_load
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

  • wybór joins czy includes zależy od kontekstu (posługiwania się danymi)
  • lazy loading - ładowanie bez danych obietków powiązanych
  • eager loading - ładowanie również danych obietków powiązanych
  • warto zaglądać w SQL generowany przez wywołania metod AR (to_sql)
  • warto przyjrzeć się narzędziu monitorującemu zapytania wykorzystywane w naszej aplikacji: https://scoutapp.com/devtrace
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