Prelude is available on RubyGems as prelude-batch-loader.
A lot of times you write code in a view that leads to an n+1, like:
<% posts.each do |post| %>
<% post.comments.featured.each do |comment| %>
<%= comment.body %>
<% end %>
<% end %>
Each of the current ways to handle this scenario come with drawbacks:
includes:ActiveRecord::Relation (not Arrays) of recordscontroller, or add includes throughout the viewbulletn+1s are detected, instead of optimizing themincludes due to the reasons aboveImagine we want to preload all of the Comments for the posts in an Array,
but only the ones that are featured. Normally this would lead to an N+1,
so we might write:
<% featured_comments_by_post_id = Comment.featured.where(post: posts).group_by(&:post_id) %>
<% posts.each do |post| %>
<% featured_comments = featured_comments_by_post_id[post.id] %>
<% featured_comments.each do |comment| %>
<%= comment.body %>
<% end %>
<% end %>
This is fine, but it's pretty annoying to write, and can be even messier when doing things like using partials to render individual items in a collection (in that case, the preload has to happen in the parent view even though the partial is where the data is actually being rendered).
This gem aims to approach the preloading in a way that's closer to ActiveRecord,
and brings the preloading closer to the data.
In the model we can define a custom preloader which takes in a collection of objects
and returns a Hash of object -> result:
class Post < ActiveRecord::Base
# An implementation which takes in an Array[Post], and returns
# a Hash[Post]=>Array[Comment]
define_prelude(:featured_comments) do |posts|
Comment.featured.where(post: posts).group_by(&:post_id)
end
end
The view stays simple:
<% posts.each.with_prelude do |post| %>
<% post.featured_comments.each do |comment| %> <%# no n+1 %>
<%= comment.body %>
<% end %>
<% end %>
The first time that featured_comments is accessed on post, the batch method
on the Model will be executed, and data will be ready for the objects in each
iteration.
You may also combine this API with strict_loading to make sure that no records
inside of an iteration load other associations without using batched loaders.
If you'd like to use Prelude with an Array of records, it's simple. Just call
with_prelude while iterating, for example:
posts = [Post.find(1), Post.find(2)] # Array, not relation
posts.each.with_prelude do |post|
post.featured_comments
end
A lot of times in our apps we end up writing a batch version of something, and an individual object version. Preloaders can be called on either a collection of objects or on a single object.
In the case of being called on a single object, they behave just like a memoized method call:
post = Post.new
post.featured_comments # hit db
post.featured_comments # memoized
It's also possible to make prelude definitions that return a Hash with a
default_proc set instead of containing the proper values. This can be useful
when it's not convenient to build the entire hash up front. (See example in the
'Arguments' section)
Prelude methods can also be defined to take in arguments. In this case, your batch method will be called one time for each unique set of arguments passed to a given method. This can often be useful when trying to preload things like authorizations for a given user:
<% breweries.each do |brewery| %>
<%= brewery.name %>
<% if brewery.has_admin?(current_user) %>
<%= link_to 'Edit', '#' %>
<% end %>
<% end %>
Where your model defines a prelude like:
define_prelude :has_admin? do |breweries, current_user|
admin_ids = current_user.admin_brewery_ids
Hash.new { |h, brewery| admin_ids.include?(brewery.id) }
end
1,223 followers · starred Dec 2020
6,993 followers · starred Aug 2021
799 followers · starred Mar 2022
168 followers · starred Apr 2025
Prelude is available on RubyGems as prelude-batch-loader.
A lot of times you write code in a view that leads to an n+1, like:
<% posts.each do |post| %>
<% post.comments.featured.each do |comment| %>
<%= comment.body %>
<% end %>
<% end %>
Each of the current ways to handle this scenario come with drawbacks:
includes:ActiveRecord::Relation (not Arrays) of recordscontroller, or add includes throughout the viewbulletn+1s are detected, instead of optimizing themincludes due to the reasons aboveImagine we want to preload all of the Comments for the posts in an Array,
but only the ones that are featured. Normally this would lead to an N+1,
so we might write:
<% featured_comments_by_post_id = Comment.featured.where(post: posts).group_by(&:post_id) %>
<% posts.each do |post| %>
<% featured_comments = featured_comments_by_post_id[post.id] %>
<% featured_comments.each do |comment| %>
<%= comment.body %>
<% end %>
<% end %>
This is fine, but it's pretty annoying to write, and can be even messier when doing things like using partials to render individual items in a collection (in that case, the preload has to happen in the parent view even though the partial is where the data is actually being rendered).
This gem aims to approach the preloading in a way that's closer to ActiveRecord,
and brings the preloading closer to the data.
In the model we can define a custom preloader which takes in a collection of objects
and returns a Hash of object -> result:
class Post < ActiveRecord::Base
# An implementation which takes in an Array[Post], and returns
# a Hash[Post]=>Array[Comment]
define_prelude(:featured_comments) do |posts|
Comment.featured.where(post: posts).group_by(&:post_id)
end
end
The view stays simple:
<% posts.each.with_prelude do |post| %>
<% post.featured_comments.each do |comment| %> <%# no n+1 %>
<%= comment.body %>
<% end %>
<% end %>
The first time that featured_comments is accessed on post, the batch method
on the Model will be executed, and data will be ready for the objects in each
iteration.
You may also combine this API with strict_loading to make sure that no records
inside of an iteration load other associations without using batched loaders.
If you'd like to use Prelude with an Array of records, it's simple. Just call
with_prelude while iterating, for example:
posts = [Post.find(1), Post.find(2)] # Array, not relation
posts.each.with_prelude do |post|
post.featured_comments
end
A lot of times in our apps we end up writing a batch version of something, and an individual object version. Preloaders can be called on either a collection of objects or on a single object.
In the case of being called on a single object, they behave just like a memoized method call:
post = Post.new
post.featured_comments # hit db
post.featured_comments # memoized
It's also possible to make prelude definitions that return a Hash with a
default_proc set instead of containing the proper values. This can be useful
when it's not convenient to build the entire hash up front. (See example in the
'Arguments' section)
Prelude methods can also be defined to take in arguments. In this case, your batch method will be called one time for each unique set of arguments passed to a given method. This can often be useful when trying to preload things like authorizations for a given user:
<% breweries.each do |brewery| %>
<%= brewery.name %>
<% if brewery.has_admin?(current_user) %>
<%= link_to 'Edit', '#' %>
<% end %>
<% end %>
Where your model defines a prelude like:
define_prelude :has_admin? do |breweries, current_user|
admin_ids = current_user.admin_brewery_ids
Hash.new { |h, brewery| admin_ids.include?(brewery.id) }
end
1,223 followers · starred Dec 2020
6,993 followers · starred Aug 2021
799 followers · starred Mar 2022
168 followers · starred Apr 2025