Skip to content

Commit 3883df5

Browse files
committed
Support activerecord associations preloader
- Add new `:preload` exposure option - Add `Grape::Entity.preload_and_represent` helper method - Add `Grape::Entity::Preloader` class for preload activerecord associations
1 parent b483791 commit 3883df5

File tree

10 files changed

+299
-4
lines changed

10 files changed

+299
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
#### Features
44

5+
* Add activerecord associations preloader to avoid N+1 queries for : new `:preload` exposure option and `Grape::Entity.preload_and_represent` helper. Requires ActiveRecord >= 7.0; otherwise a warning is emitted and no preload is performed.
56
* Your contribution here.
67

78
#### Fixes

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ group :development, :test do
1717
end
1818

1919
group :test do
20+
gem 'activerecord'
2021
gem 'coveralls_reborn', require: false
2122
gem 'growl'
2223
gem 'guard'
@@ -25,4 +26,5 @@ group :test do
2526
gem 'rb-fsevent'
2627
gem 'ruby-grape-danger', '~> 0.2', require: false
2728
gem 'simplecov', require: false
29+
gem 'sqlite3'
2830
end

README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
- [Format Before Exposing](#format-before-exposing)
2626
- [Expose Nil](#expose-nil)
2727
- [Default Value](#default-value)
28+
- [ActiveRecord Associations Preloader](#activerecord-associations-preloader)
2829
- [Documentation](#documentation)
2930
- [Options Hash](#options-hash)
3031
- [Passing Additional Option To Nested Exposure](#passing-additional-option-to-nested-exposure)
@@ -510,6 +511,77 @@ module Entities
510511
end
511512
```
512513

514+
#### ActiveRecord Associations Preloader
515+
516+
Avoid N+1 queries by preload ActiveRecord associations. You can declare which association to preload per exposure via the `:preload` option, and perform representation with `preload_and_represent`.
517+
518+
- Requirements: ActiveRecord >= 7.0. On lower versions, a warning is emitted and no preload is performed.
519+
- The `:preload` option value must be a Symbol.
520+
- Respects `only`/`except` options when deciding which associations to preload.
521+
522+
Example entity definitions with `:preload`:
523+
524+
```ruby
525+
class Tag < ApplicationRecord
526+
include Grape::Entity::DSL
527+
528+
belongs_to :target, polymorphic: true
529+
530+
entity do
531+
# ...
532+
end
533+
end
534+
535+
class Book < ApplicationRecord
536+
include Grape::Entity::DSL
537+
538+
belongs_to :author, foreign_key: :author_id, class_name: 'User'
539+
has_many :tags, as: :target
540+
541+
entity do
542+
# ...
543+
expose :tags, using: Tag::Entity, preload: :tags
544+
end
545+
end
546+
547+
class User < ApplicationRecord
548+
include Grape::Entity::DSL
549+
550+
has_many :books, foreign_key: :author_id
551+
has_many :tags, as: :target
552+
553+
entity do
554+
# ...
555+
expose :books, using: Book::Entity, preload: :books
556+
expose :tags, using: Tag::Entity, preload: :tags
557+
end
558+
end
559+
```
560+
561+
Preload and represent in one call:
562+
563+
```ruby
564+
# Preload all declared associations that will be exposed
565+
User::Entity.preload_and_represent(users)
566+
567+
# Only preload what will be exposed (respects :only / :except)
568+
User::Entity.preload_and_represent(users, only: [:tags])
569+
User::Entity.preload_and_represent(users, except: [:tags])
570+
```
571+
572+
Nested exposure preloading also works, for example:
573+
574+
```ruby
575+
class UserWithNest < User::Entity
576+
unexpose :books
577+
expose :nesting do
578+
expose :books, using: Book::Entity, preload: :books
579+
end
580+
end
581+
582+
UserWithNest.preload_and_represent(users)
583+
```
584+
513585
#### Documentation
514586

515587
Expose documentation with the field. Gets bubbled up when used with Grape and various API documentation systems.

lib/grape_entity.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
require 'grape_entity/exposure'
1111
require 'grape_entity/options'
1212
require 'grape_entity/deprecated'
13+
require 'grape_entity/preloader'

lib/grape_entity/entity.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,12 @@ def self.inherited(subclass)
187187
# @option options :merge This option allows you to merge an exposed field to the root
188188
#
189189
# rubocop:disable Layout/LineLength
190-
def self.expose(*args, &block)
190+
def self.expose(*args, &block) # rubocop:disable Metrics/AbcSize
191191
options = merge_options(args.last.is_a?(Hash) ? args.pop : {})
192192

193-
if args.size > 1
193+
raise ArgumentError, 'The :preload option must be a Symbol.' if options.key?(:preload) && !options[:preload].is_a?(Symbol)
194194

195+
if args.size > 1
195196
raise ArgumentError, 'You may not use the :as option on multi-attribute exposures.' if options[:as]
196197
raise ArgumentError, 'You may not use the :expose_nil on multi-attribute exposures.' if options.key?(:expose_nil)
197198
raise ArgumentError, 'You may not use block-setting on multi-attribute exposures.' if block_given?
@@ -451,6 +452,14 @@ def self.represent(objects, options = {})
451452
root_element ? { root_element => inner } : inner
452453
end
453454

455+
# Same as the represent method, but the activerecord associations declared by the preload option will be preloaded,
456+
# Therefore, it can avoid n+1 queries.
457+
def self.preload_and_represent(objects, options = {})
458+
options = Options.new(options) unless options.is_a?(Options)
459+
Preloader.new(self, objects, options).call
460+
represent(objects, options)
461+
end
462+
454463
# This method returns the entity's root or collection root node, or its parent's
455464
# @param root_type: either :collection_root or just :root
456465
def self.root_element(root_type)
@@ -618,6 +627,7 @@ def to_xml(options = {})
618627
expose_nil
619628
override
620629
default
630+
preload
621631
].to_set.freeze
622632

623633
# Merges the given options with current block options.

lib/grape_entity/exposure/base.rb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module Grape
77
class Entity
88
module Exposure
99
class Base
10-
attr_reader :attribute, :is_safe, :documentation, :override, :conditions, :for_merge
10+
attr_reader :attribute, :is_safe, :documentation, :override, :conditions, :for_merge, :preload
1111

1212
def self.new(attribute, options, conditions, ...)
1313
super(attribute, options, conditions).tap { |e| e.setup(...) }
@@ -24,9 +24,14 @@ def initialize(attribute, options, conditions)
2424
@attr_path_proc = options[:attr_path]
2525
@documentation = options[:documentation]
2626
@override = options[:override]
27+
@preload = options[:preload]
2728
@conditions = conditions
2829
end
2930

31+
def preload?
32+
!preload.nil?
33+
end
34+
3035
def dup(&block)
3136
self.class.new(*dup_args, &block)
3237
end
@@ -116,8 +121,12 @@ def attr_path(entity, options)
116121
end
117122
end
118123

124+
def proc_key?
125+
@key.respond_to?(:call)
126+
end
127+
119128
def key(entity = nil)
120-
@key.respond_to?(:call) ? entity.exec_with_object(@options, &@key) : @key
129+
proc_key? ? entity.exec_with_object(@options, &@key) : @key
121130
end
122131

123132
def with_attr_path(entity, options, &block)

lib/grape_entity/preloader.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
module Grape
4+
class Entity
5+
class Preloader
6+
attr_reader :entity_class, :objects, :options
7+
8+
def initialize(entity_class, objects, options)
9+
@entity_class = entity_class
10+
@objects = Array.wrap(objects)
11+
@options = options
12+
end
13+
14+
if defined?(ActiveRecord) && ActiveRecord.respond_to?(:version) && ActiveRecord.version >= Gem::Version.new('7.0')
15+
def call
16+
associations = {}
17+
collect_associations(entity_class.root_exposures, associations, options)
18+
ActiveRecord::Associations::Preloader.new(records: objects, associations: associations).call
19+
end
20+
else
21+
def call
22+
warn 'The Preloader work normally requires activerecord(>= 7.0) gem'
23+
end
24+
end
25+
26+
private
27+
28+
def collect_associations(exposures, associations, options)
29+
exposures.each do |exposure|
30+
next unless exposure.should_return_key?(options)
31+
32+
new_associations = associations[exposure.preload] ||= {} if exposure.preload?
33+
next if exposure.proc_key?
34+
35+
if exposure.is_a?(Exposure::NestingExposure)
36+
collect_associations(exposure.nested_exposures, associations, subexposure_options_for(exposure, options))
37+
elsif exposure.is_a?(Exposure::RepresentExposure) && new_associations
38+
collect_associations(exposure.using_class.root_exposures, new_associations, subexposure_options_for(exposure, options)) # rubocop:disable Layout/LineLength
39+
end
40+
end
41+
end
42+
43+
def subexposure_options_for(exposure, options)
44+
options.for_nesting(exposure.instance_variable_get(:@key))
45+
end
46+
end
47+
end
48+
end

spec/grape_entity/entity_spec.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
it 'makes sure unknown options are not silently ignored' do
4141
expect { subject.expose :name, unknown: nil }.to raise_error ArgumentError
4242
end
43+
44+
it 'ensures :preload option must be a Symbol' do
45+
expect { subject.expose :name, preload: 'author' }.to raise_error ArgumentError
46+
expect { subject.expose :name, preload: :author }.not_to raise_error
47+
end
4348
end
4449

4550
context 'with a :merge option' do
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
RSpec.describe Grape::Entity::Preloader do
6+
class SQLCounter
7+
class << self
8+
attr_accessor :ignored_sql, :log, :log_all
9+
10+
def clear_log
11+
self.log = []
12+
self.log_all = []
13+
end
14+
end
15+
clear_log
16+
17+
def call(_name, _start, _finish, _message_id, values)
18+
return if values[:cached]
19+
20+
sql = values[:sql]
21+
self.class.log_all << sql
22+
self.class.log << sql unless %w[SCHEMA TRANSACTION].include? values[:name]
23+
end
24+
25+
ActiveSupport::Notifications.subscribe('sql.active_record', new)
26+
end
27+
28+
class ApplicationRecord < ActiveRecord::Base
29+
self.abstract_class = true
30+
end
31+
32+
class Tag < ApplicationRecord
33+
include Grape::Entity::DSL
34+
35+
connection.create_table(:tags) do |t|
36+
t.string :name
37+
t.references :target, polymorphic: true
38+
end
39+
40+
belongs_to :target, polymorphic: true
41+
42+
entity do
43+
expose :name
44+
end
45+
end
46+
47+
class Book < ApplicationRecord
48+
include Grape::Entity::DSL
49+
50+
connection.create_table(:books) do |t|
51+
t.string :name
52+
t.references :author
53+
end
54+
55+
belongs_to :author, foreign_key: :author_id, class_name: 'User'
56+
has_many :tags, as: :target, dependent: :destroy
57+
58+
entity do
59+
expose :name
60+
expose :tags, using: Tag::Entity, preload: :tags
61+
end
62+
end
63+
64+
class User < ApplicationRecord
65+
include Grape::Entity::DSL
66+
67+
connection.create_table(:users) do |t|
68+
t.string :name
69+
end
70+
71+
has_many :books, foreign_key: :author_id, dependent: :destroy
72+
has_many :tags, as: :target, dependent: :destroy
73+
74+
entity do
75+
expose :name
76+
expose :books, using: Book::Entity, preload: :books
77+
expose :tags, using: Tag::Entity, preload: :tags
78+
end
79+
end
80+
81+
let!(:users) { [User.create(name: 'User1'), User.create(name: 'User2')] }
82+
let!(:user_tags) { [Tag.create(name: 'Tag1', target: users[0]), Tag.create(name: 'Tag2', target: users[1])] }
83+
let!(:books) { [Book.create(name: 'Book1', author: users[0]), Book.create(name: 'Book2', author: users[1])] }
84+
let!(:book_tags) { [Tag.create(name: 'Tag1', target: books[0]), Tag.create(name: 'Tag2', target: books[1])] }
85+
86+
before { SQLCounter.clear_log }
87+
88+
it 'preload associations through RepresentExposure' do
89+
User::Entity.preload_and_represent(users)
90+
91+
expect(SQLCounter.log).to eq([
92+
'SELECT "books".* FROM "books" WHERE "books"."author_id" IN (?, ?)',
93+
'SELECT "tags".* FROM "tags" WHERE "tags"."target_type" = ? AND "tags"."target_id" IN (?, ?)',
94+
'SELECT "tags".* FROM "tags" WHERE "tags"."target_type" = ? AND "tags"."target_id" IN (?, ?)'
95+
])
96+
end
97+
98+
it 'preload associations through NestingExposure' do
99+
Class.new(User::Entity) do
100+
unexpose :books
101+
expose :nesting do
102+
expose :books, using: Book::Entity, preload: :books
103+
end
104+
end.preload_and_represent(users)
105+
106+
expect(SQLCounter.log).to eq([
107+
'SELECT "books".* FROM "books" WHERE "books"."author_id" IN (?, ?)',
108+
'SELECT "tags".* FROM "tags" WHERE "tags"."target_type" = ? AND "tags"."target_id" IN (?, ?)',
109+
'SELECT "tags".* FROM "tags" WHERE "tags"."target_type" = ? AND "tags"."target_id" IN (?, ?)'
110+
])
111+
end
112+
113+
it 'preload same associations multiple times' do
114+
Class.new(User::Entity) do
115+
expose(:other_books, preload: :books) { :other_books }
116+
end.preload_and_represent(users)
117+
118+
expect(SQLCounter.log).to eq([
119+
'SELECT "books".* FROM "books" WHERE "books"."author_id" IN (?, ?)',
120+
'SELECT "tags".* FROM "tags" WHERE "tags"."target_type" = ? AND "tags"."target_id" IN (?, ?)',
121+
'SELECT "tags".* FROM "tags" WHERE "tags"."target_type" = ? AND "tags"."target_id" IN (?, ?)'
122+
])
123+
end
124+
125+
describe 'only preload associations that are specified in the options' do
126+
it 'through :only option' do
127+
User::Entity.preload_and_represent(users, only: [:tags])
128+
129+
expect(SQLCounter.log).to eq([
130+
'SELECT "tags".* FROM "tags" WHERE "tags"."target_type" = ? AND "tags"."target_id" IN (?, ?)'
131+
])
132+
end
133+
134+
it 'through :except option' do
135+
User::Entity.preload_and_represent(users, except: [:tags])
136+
137+
expect(SQLCounter.log).to eq([
138+
'SELECT "books".* FROM "books" WHERE "books"."author_id" IN (?, ?)',
139+
'SELECT "tags".* FROM "tags" WHERE "tags"."target_type" = ? AND "tags"."target_id" IN (?, ?)'
140+
])
141+
end
142+
end
143+
end

0 commit comments

Comments
 (0)