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 zrelationships
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
czyincludes
zależy od kontekstu (posługiwania się danymi) lazy loading
– ładowanie bez danych obietków powiązanycheager 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