README
What is this?
It's a Rails 8 repo with some ActiveRecord examples, based on notes I took when reading
Advanced Active Record
Prerequisites
- Docker
Setup
- Clone this repo
docker compose run web bin/setup --skip-server --reset
has_one
These are ActiveRecord examples with a simple has_one relationship
Raises an error, because one of the accounts is missing a supplier (this was intentional)
Account.where("account_number ilike '%123%'").map { |account| account.supplier.name }
Account Load (0.6ms) SELECT "accounts".* FROM "accounts" WHERE (account_number ilike '%123%')
Supplier Load (0.3ms) SELECT "suppliers".* FROM "suppliers" WHERE "suppliers"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Supplier Load (0.1ms) SELECT "suppliers".* FROM "suppliers" WHERE "suppliers"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
Supplier Load (0.1ms) SELECT "suppliers".* FROM "suppliers" WHERE "suppliers"."id" = $1 LIMIT $2 [["id", 3], ["LIMIT", 1]]
(active-record-stuff):7:in 'block in <main>': undefined method 'name' for nil (NoMethodError)
from (active-record-stuff):7:in 'Enumerable#map'
from (active-record-stuff):7:in '<main>'This fixes that, but for that error (missing data) it creates an N+1
Account.joins(:supplier).where("account_number ilike '%123%'").map { |account| account.supplier.name }
Account Load (1.7ms) SELECT "accounts".* FROM "accounts" INNER JOIN "suppliers" ON "suppliers"."id" = "accounts"."supplier_id" WHERE (account_number ilike '%123%')
Supplier Load (0.6ms) SELECT "suppliers".* FROM "suppliers" WHERE "suppliers"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Supplier Load (0.2ms) SELECT "suppliers".* FROM "suppliers" WHERE "suppliers"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
Supplier Load (0.1ms) SELECT "suppliers".* FROM "suppliers" WHERE "suppliers"."id" = $1 LIMIT $2 [["id", 3], ["LIMIT", 1]]This fixes the N+1, but takes us back to the nil supplier error
Account.includes(:supplier).where("account_number ilike '%123%'").map { |account| account.supplier.name }
Account Load (0.6ms) SELECT "accounts".* FROM "accounts" WHERE (account_number ilike '%123%')
Supplier Load (0.4ms) SELECT "suppliers".* FROM "suppliers" WHERE "suppliers"."id" IN ($1, $2, $3) [["id", 1], ["id", 2], ["id", 3]]
(active-record-stuff):4:in 'block in <main>': undefined method 'name' for nil (NoMethodError)
from (active-record-stuff):4:in 'Enumerable#map'
from (active-record-stuff):4:in '<main>'Using joins + includes takes care of both, and you get a single query.
Note that active record chooses to do an eager load. The docs
explain it
Account.joins(:supplier).includes(:supplier).where("account_number ilike '%123%'").map { |account| account.supplier.name }
Account Eager Load (2.7ms) SELECT "accounts"."id" AS t0_r0, "accounts"."account_number" AS t0_r1, "accounts"."created_at" AS t0_r2, "accounts"."supplier_id" AS t0_r3, "accounts"."updated_at" AS t0_r4, "suppliers"."id" AS t1_r0, "suppliers"."created_at" AS t1_r1, "suppliers"."name" AS t1_r2, "suppliers"."updated_at" AS t1_r3 FROM "accounts" INNER JOIN "suppliers" ON "suppliers"."id" = "accounts"."supplier_id" WHERE (account_number ilike '%123%')You can also force the eager load so it does 1 query
Account.joins(:supplier).eager_load(:supplier).where("account_number ilike '%123%'").map { |account| account.supplier.name }
Account Eager Load (0.7ms) SELECT "accounts"."id" AS t0_r0, "accounts"."account_number" AS t0_r1, "accounts"."created_at" AS t0_r2, "accounts"."supplier_id" AS t0_r3, "accounts"."updated_at" AS t0_r4, "suppliers"."id" AS t1_r0, "suppliers"."created_at" AS t1_r1, "suppliers"."name" AS t1_r2, "suppliers"."updated_at" AS t1_r3 FROM "accounts" INNER JOIN "suppliers" ON "suppliers"."id" = "accounts"."supplier_id" WHERE (account_number ilike '%123%')Or force the preload so it does 2 queries (1 each for account + suppliers)
Account.joins(:supplier).preload(:supplier).where("account_number ilike '%123%'").map { |account| account.supplier.name }
Account Load (0.7ms) SELECT "accounts".* FROM "accounts" INNER JOIN "suppliers" ON "suppliers"."id" = "accounts"."supplier_id" WHERE (account_number ilike '%123%')
Supplier Load (0.4ms) SELECT "suppliers".* FROM "suppliers" WHERE "suppliers"."id" IN ($1, $2, $3) [["id", 1], ["id", 2], ["id", 3]]When your where clause doesn't reference the includes table, ActiveRecord chooses a preload
Supplier.includes(:account).where("name ilike '%Acme%'")
Supplier Load (0.5ms) SELECT "suppliers".* FROM "suppliers" WHERE (name ilike '%Acme%') /* loading for pp */ LIMIT $1 [["LIMIT", 11]]
Account Load (0.5ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."supplier_id" = $1 [["supplier_id", 1]]If you want to reference the included table from your where clause, ActiveRecord will (can?) choose an eager_load and
force a left_join
Supplier.includes(:account).references(:account).where("accounts.account_number ilike '%123%'")
Supplier Eager Load (2.6ms) SELECT "suppliers"."id" AS t0_r0, "suppliers"."created_at" AS t0_r1, "suppliers"."name" AS t0_r2, "suppliers"."updated_at" AS t0_r3, "accounts"."id" AS t1_r0, "accounts"."account_number" AS t1_r1, "accounts"."created_at" AS t1_r2, "accounts"."supplier_id" AS t1_r3, "accounts"."updated_at" AS t1_r4 FROM "suppliers" LEFT OUTER JOIN "accounts" ON "accounts"."supplier_id" = "suppliers"."id" WHERE (accounts.account_number ilike '%123%') /* loading for pp */ LIMIT $1 [["LIMIT", 11]]Use a joins to force a join, ignoring any records missing a related record
Account.joins(:supplier).where("account_number ilike '%123%'")
Account Load (0.9ms) SELECT "accounts".* FROM "accounts" INNER JOIN "suppliers" ON "suppliers"."id" = "accounts"."supplier_id" WHERE (account_number ilike '%123%') /* loading for pp */ LIMIT $1 [["LIMIT", 11]]Using joins plus eager_load to query across
Account.joins(:supplier).eager_load(:supplier).where("suppliers.name ilike '%Acme%'")
Account Eager Load (1.0ms) SELECT "accounts"."id" AS t0_r0, "accounts"."account_number" AS t0_r1, "accounts"."created_at" AS t0_r2, "accounts"."supplier_id" AS t0_r3, "accounts"."updated_at" AS t0_r4, "suppliers"."id" AS t1_r0, "suppliers"."created_at" AS t1_r1, "suppliers"."name" AS t1_r2, "suppliers"."updated_at" AS t1_r3 FROM "accounts" INNER JOIN "suppliers" ON "suppliers"."id" = "accounts"."supplier_id" WHERE (suppliers.name ilike '%Acme%') /* loading for pp */ LIMIT $1 [["LIMIT", 11]]joins vs. includes
One final example, a comparison between a joins:
Account.joins(:supplier).eager_load(:supplier).where("suppliers.name ~ '[aeiou]'")SELECT
"accounts"."id" AS t0_r0,
"accounts"."account_number" AS t0_r1,
"accounts"."created_at" AS t0_r2,
"accounts"."supplier_id" AS t0_r3,
"accounts"."updated_at" AS t0_r4,
"suppliers"."id" AS t1_r0,
"suppliers"."created_at" AS t1_r1,
"suppliers"."name" AS t1_r2,
"suppliers"."updated_at" AS t1_r3
FROM
"accounts"
INNER JOIN "suppliers" ON "suppliers"."id" = "accounts"."supplier_id"
WHERE
(suppliers.name ~ '[aeiou]')And an includes:
Account.includes(:supplier).eager_load(:supplier).where("suppliers.name ~ '[aeiou]'")SELECT
"accounts"."id" AS t0_r0,
"accounts"."account_number" AS t0_r1,
"accounts"."created_at" AS t0_r2,
"accounts"."supplier_id" AS t0_r3,
"accounts"."updated_at" AS t0_r4,
"suppliers"."id" AS t1_r0,
"suppliers"."created_at" AS t1_r1,
"suppliers"."name" AS t1_r2,
"suppliers"."updated_at" AS t1_r3
FROM
"accounts"
LEFT OUTER JOIN "suppliers" ON "suppliers"."id" = "accounts"."supplier_id"
WHERE
(suppliers.name ~ '[aeiou]')The primary difference is:
joinsuses anINNER JOINincludesuses aLEFT OUTER JOIN
The outcome is the same, but the performance likely won't be for certain scenarios. The joins is likely to be the more
efficient version (comparing query plans, at least)