-
Notifications
You must be signed in to change notification settings - Fork 21.9k
Description
Recently @eileencodes made me aware of this PullRequest #32136 and plans to possibly make Preloader
a Public API.
We've been using Associations:Preloader
at GitHub as part of our https://github.com/Shopify/graphql-batch setup to preload associations during GraphQL queries. Over the past month we've hit some pretty nasty issues with it. I believe these may have to be fixed before making the Preloader a public concern.
Loading already loaded records
The main issue is this line: https://github.com/rails/rails/blob/master/activerecord/lib/active_record/associations/preloader.rb#L180
When preloading an association on a list of records, if this association is already loaded on the first record of the list, it is assumed that the whole list is loaded. In these cases, a AlreadyLoaded
preloader is returned, and uses association(name).target
to read the preloaded records.
In a public setting, I don't think we can make this assumption, in fact, using preload
with a list of records might result in this scenario:
a = Record.create!
b = Record.create!
c = Record.create!
a.some_association.create!
preloaders = ActiveRecord::Associations::Preloader.new.preload([a,b,c], [:some_association])
preloaders.first.preloaded_records
=> [Only the association for a is preloaded]
At GitHub we worked around this problem by always checking if the association was already loaded, before passing a record to Preloader
, filtering them out if needed. That solved most issues until we hit issues that were out of our control.
Take through associations for example:
https://github.com/rails/rails/blob/master/activerecord/lib/active_record/associations/preloader/through_association.rb#L8-L14
If we take
has_many :patients, through: :appointments , source: :client
First, appointments are preloaded on patients. Then, client
is preloaded on all patients. If the first appointment's client was already loaded, we'll get back only the client.
When returning the preloaded records, we'll try to get the target association on all records, but the whole tail of the list of records will be nil
because they weren't actually loaded.
In certain versions of Rails, association.reader
is used instead of target
if the association is not loaded. This is almost worst because it will silently generate N+1's.
Another gotcha this behaviour introduces is that you cannot use Preloader
with different types of records / associations.
For example, one issue we hit was that we were preloading an association on two different types of records at the same time:
Preloader.new.preload([doctor, patient, patient_b], [:appointment])
In our case, appoitments
on one of the records was a has_one through:
, and the other one was a belongs to
. During the has_one through
preloading logic, an association was loaded on the other kind of record. When it was time to load the second kind of record, since the association was already loaded for the first record, Preloader was skipping the whole list again.
If needed, I can provide failing tests for some of these tricky scenarios.
Preloading different object_ids
Another smaller issue is that the preloader currently uniq!
the records before preloading them. This means that passing multiple objects for the same record will result in only the first record being preloaded.
We work around that by deduping the records before passing them to Preloader, and we copy over the associations after.
Takeaways
Currently, Rails assumes the preloader is ran in a certain context (its a private API right now after all). It breaks quickly when its used in other ways. It would be really awesome for us to make it a Public API, and it would be great to find a way around these gotchas!