Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Rails: eager-loading on an already-left-joined table?

We are already left-joining a table so that we can order by a column, if the relation exists:

people = Person
  .joins("LEFT JOIN addresses ON addresses.id = people.address_id")
  .order("addresses.country")
  .all

This results in a single SQL query, but I'd like to have people.first.address not trigger SQL to load the address. I am left-joining because some people don't have addresses.

.includes(:address) triggers a separate query.

You can do what I'm suggesting with inner joins, using includes, but that triggers 2 SQL queries:

Person.includes(:address).all

While joins + includes triggers only one (but INNER joins):

Person.joins(:address).includes(:address).all

Active record also uses left joins if you force a join while eager-loading .eager_load(:addresses).

Can you take an existing left-join and have rails eager-load with those results? So far I can't find this.

like image 743
swajak Avatar asked Jan 25 '18 18:01

swajak


3 Answers

Try this:

people = Person.
  eager_load(:address).
  merge(Address.order("coalesce(country, '')")).
  all

people.first.address

eager_load forces eager loading by performing a LEFT OUTER JOIN.

I added a coalesce on country so you have more control over where people without addresses appear in the results.

Here is what it looks like for me:

people = Person.
  eager_load(:address).
  merge(Address.order("coalesce(country, '')")).
  all

(0.5ms)  SELECT DISTINCT COUNT(DISTINCT "people"."id") FROM "people" LEFT OUTER JOIN "addresses" ON "addresses"."person_id" = "people"."id"
SQL (2.1ms)  SELECT "people"."id" AS t0_r0, "people"."name" AS t0_r1, "people"."created_at" AS t0_r2, "people"."updated_at" AS t0_r3, "addresses"."id" AS t1_r0, "addresses"."person_id" AS t1_r1, "addresses"."address" AS t1_r2, "addresses"."country" AS t1_r3, "addresses"."created_at" AS t1_r4, "addresses"."updated_at" AS t1_r5 FROM "people" LEFT OUTER JOIN "addresses" ON "addresses"."person_id" = "people"."id" ORDER BY coalesce(addresses.country, '')


people.first.address
nil

people.last.address
#<Address:0x007febabb508a8> {
            :id => 1,
     :person_id => 4,
       :address => "24175 Gerhold Prairie",
       :country => "O",
    :created_at => Thu, 01 Feb 2018 18:47:45 UTC +00:00,
    :updated_at => Thu, 01 Feb 2018 18:47:45 UTC +00:00
}

Note that no queries are run when you access the addresses

I'll point that you've ordered by a table that left outer joined, so you need to decide how you want to treat nulls.

like image 192
John Naegle Avatar answered Oct 13 '22 08:10

John Naegle


You can uses #references with #includes to LEFT JOIN your relation and achieve what you are after.

people = Person
  .includes(:addresses)
  .references(:addresses)
  .order("addresses.country")

This will yield one query and all the people, whether or not they have an address, and also eager load the addresses to avoid the dreaded N + 1 queries.

The documentation doesn't do a great job of explaining that #references will add a LEFT JOIN but it's purpose is to allow adding SQL clauses (WHERE, ORDER, GROUP, etc.) on the relation in conjunction with #includes.

like image 41
dave_slash_null Avatar answered Oct 13 '22 06:10

dave_slash_null


Well, in your case you could select your query creating an alias for the fields you are going to use from address something like this:

people = Person
         .joins("LEFT JOIN addresses ON addresses.id = people.address_id")
         .select("people.*, addresses.country as address_country")
         .order("address_country")
         .all

This won't change your desired query and will not result in extra queries, not for country at least.

like image 25
ErvalhouS Avatar answered Oct 13 '22 08:10

ErvalhouS