grape-entity-preloader 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +55 -28
- data/lib/grape/entity/preloader/entity.rb +3 -4
- data/lib/grape/entity/preloader/exposure.rb +103 -0
- data/lib/grape/entity/preloader/options.rb +22 -2
- data/lib/grape/entity/preloader/version.rb +1 -1
- data/lib/grape/entity/preloader.rb +95 -77
- data/spec/grape/entity/preloader_spec.rb +227 -0
- data/spec/n_plus_one_op_spec.rb +21 -11
- data/spec/support/models/book.rb +18 -18
- data/spec/support/models/user.rb +38 -38
- metadata +3 -3
- data/lib/grape/entity/preloader/exposure/base.rb +0 -46
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 671275e5b668d11b22c54c7475aa8eb9ec91836a4f35549ed954af02d1e21eda
|
|
4
|
+
data.tar.gz: edaeb202a4ca0b7d56fec26d6d3fe07ad01b39391096bdfc0f25b4d7fb6f6fd7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 13377f5e77e915f68c9d046fefd0ad109f26e65f0e2af35db5e265e0c3d09f703197b1dfb76bb3f0c64dc0243c7232839a3538d428a9860c0d5139257d69a663
|
|
7
|
+
data.tar.gz: f6413435b1765b7ac3efbe5fe1960d0cd65c2c7e255eb2e764baccde3d1d242b96b1173435a7f36d694208df8678a973443a990bf462e35d848114277704754c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.0.0] - 2026-08-14 UTC
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Deduplicate identical preload callbacks.
|
|
8
|
+
- Warn when skipping preloading for exposures with dynamic key or dynamic attr_path.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **Breaking**: Merge `:preload_association`, `:preload_callback`, and `:preload_condition` into a single `:preload` option on exposures. The option now accepts:
|
|
13
|
+
- a Symbol for an ActiveRecord association (`preload: :books`);
|
|
14
|
+
- a Proc for a callback (`preload: ->(objects, options) { ... }`);
|
|
15
|
+
- an Array combining the above with an optional condition Proc (`preload: [:books, ->(options) { ... }]` or `preload: [->(objects, options) { ... }, ->(options) { ... }]`).
|
|
16
|
+
- **Breaking**: Require `preload_callback` Proc to return a Hash mapping each object to its preloaded value, instead of an Array of values. Preloaded values are cached in `options` and reused across exposures at the same nesting level.
|
|
17
|
+
- Defer ActiveRecord version check to runtime.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Keep consistent `attr_path` for nested exposures when preloader is enabled.
|
|
22
|
+
- Disable preloader during nested entity serialization instead of at root `represent`, preventing duplicate preloads for deferred serialization.
|
|
23
|
+
- Fix cyclic entity references during preload option extraction to avoid infinite recursion.
|
|
24
|
+
|
|
25
|
+
## [0.3.0] - 2026-03-17 UTC
|
|
26
|
+
|
|
27
|
+
- Ensure `with_enable` and `with_disable` methods return block result
|
|
28
|
+
|
|
3
29
|
## [0.2.0] - 2025-10-14 UTC
|
|
4
30
|
|
|
5
31
|
- ⚠️ [Broken] Remove `:grape_entity_preloader` option functionality, please use `Grape::Entity::Preloader.with_enable` or `Grape::Entity::Preloader.with_disable` instead.
|
data/README.md
CHANGED
|
@@ -47,16 +47,20 @@ Grape::Entity::Preloader.with_disable do
|
|
|
47
47
|
end
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
### `
|
|
50
|
+
### `preload`
|
|
51
51
|
|
|
52
|
-
Use `
|
|
52
|
+
Use `preload` to configure preloading for an exposure. It can be a `Symbol` to preload an ActiveRecord association, a `Proc` to run a custom preload callback, or an `Array` with an optional condition.
|
|
53
|
+
|
|
54
|
+
#### Association preloading
|
|
55
|
+
|
|
56
|
+
A `Symbol` value preloads the named association:
|
|
53
57
|
|
|
54
58
|
```ruby
|
|
55
59
|
class UserEntity < Grape::Entity
|
|
56
60
|
expose :id
|
|
57
61
|
expose :name
|
|
58
62
|
# This will preload the `books` association for all users being represented.
|
|
59
|
-
expose :books, using: BookEntity,
|
|
63
|
+
expose :books, using: BookEntity, preload: :books
|
|
60
64
|
end
|
|
61
65
|
|
|
62
66
|
# In your API
|
|
@@ -74,28 +78,29 @@ class BookEntity < Grape::Entity
|
|
|
74
78
|
expose :id
|
|
75
79
|
expose :title
|
|
76
80
|
# This will preload tags for each book
|
|
77
|
-
expose :tags, using: TagEntity,
|
|
81
|
+
expose :tags, using: TagEntity, preload: :tags
|
|
78
82
|
end
|
|
79
83
|
|
|
80
84
|
class UserEntity < Grape::Entity
|
|
81
85
|
expose :id
|
|
82
86
|
expose :name
|
|
83
|
-
expose :books, using: BookEntity,
|
|
87
|
+
expose :books, using: BookEntity, preload: :books
|
|
84
88
|
end
|
|
85
89
|
|
|
86
90
|
# It will generate 3 queries instead of 1 + 10 (for books) + N (for tags)
|
|
87
91
|
UserEntity.represent(User.limit(10))
|
|
88
92
|
```
|
|
89
93
|
|
|
90
|
-
|
|
94
|
+
#### Custom preloading callback
|
|
91
95
|
|
|
92
|
-
For more complex scenarios that
|
|
96
|
+
For more complex scenarios that association preloading doesn't cover (e.g., loading data from other services, custom caching logic), you can use a `Proc` that accepts two arguments:
|
|
93
97
|
|
|
94
|
-
It must be a `Proc` that accepts two arguments:
|
|
95
98
|
1. `objects`: An array of the parent objects being represented.
|
|
96
99
|
2. `options`: The `Grape::Entity::Options` object for the current representation context.
|
|
97
100
|
|
|
98
|
-
|
|
101
|
+
This is the same `objects` / `options` signature used by conditional preloading (see below).
|
|
102
|
+
|
|
103
|
+
**The `Proc` must return a `Hash` mapping each parent object to its preloaded value.** The preloader stores this Hash in `options` and reads from it when rendering the exposure. If the cache is not available, the exposure fallback to its normal value method (delegation or block).
|
|
99
104
|
|
|
100
105
|
```ruby
|
|
101
106
|
class UserStatsEntity < Grape::Entity
|
|
@@ -107,36 +112,46 @@ class UserEntity < Grape::Entity
|
|
|
107
112
|
expose :id
|
|
108
113
|
expose :name
|
|
109
114
|
|
|
110
|
-
expose :stats, using: UserStatsEntity,
|
|
115
|
+
expose :stats, using: UserStatsEntity, preload: ->(users, _options) do
|
|
111
116
|
# `users` is an array of User objects.
|
|
112
117
|
# Here you can fetch stats for all users in one batch.
|
|
113
|
-
|
|
114
|
-
stats_data = StatsService.batch_get_by_user_ids(user_ids) # returns a hash { user_id => stats_object }
|
|
118
|
+
stats_by_user_id = StatsService.batch_get_by_user_ids(users.map(&:id))
|
|
115
119
|
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
# In this case, it's the stats objects we just loaded.
|
|
122
|
-
users.map { |user| user.instance_variable_get(:@stats) }
|
|
120
|
+
# Return a Hash mapping each user to its stats object.
|
|
121
|
+
users.to_h { |user| [user, stats_by_user_id[user.id]] }
|
|
122
|
+
end do |user, _options|
|
|
123
|
+
# Fallback when the preloader is disabled.
|
|
124
|
+
StatsService.get(user.id)
|
|
123
125
|
end
|
|
124
126
|
end
|
|
127
|
+
```
|
|
125
128
|
|
|
126
|
-
|
|
129
|
+
**Callback deduplication**
|
|
130
|
+
|
|
131
|
+
When several `expose` declarations need the same preloaded data, reference the same `preload` callback `Proc` for each of them. The preloader will run that callback once, store the resulting Hash in `options`, and reuse it for all of the associated exposures, avoiding duplicate work.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
127
134
|
class UserEntity < Grape::Entity
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
user.
|
|
135
|
+
stats_callback = ->(users, _options) do
|
|
136
|
+
stats_by_user_id = StatsService.batch_get(users.map(&:id))
|
|
137
|
+
users.to_h { |user| [user, stats_by_user_id[user.id]] }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
expose :public_stats, using: StatsEntity, preload: stats_callback do |user, _options|
|
|
141
|
+
StatsService.get(user.id)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
expose :private_stats, using: StatsEntity, preload: stats_callback do |user, _options|
|
|
145
|
+
StatsService.get(user.id)
|
|
131
146
|
end
|
|
132
147
|
end
|
|
133
148
|
```
|
|
134
149
|
|
|
135
|
-
|
|
150
|
+
#### Conditional preloading
|
|
136
151
|
|
|
137
|
-
|
|
152
|
+
When you need a condition, pass an `Array` where the first element is the preload value and the second element is a `Proc` that accepts one argument: `options`.
|
|
138
153
|
|
|
139
|
-
If the `Proc` returns a falsy value, preloading for that exposure will be skipped.
|
|
154
|
+
If the condition `Proc` returns a falsy value, preloading for that exposure will be skipped.
|
|
140
155
|
|
|
141
156
|
```ruby
|
|
142
157
|
class UserEntity < Grape::Entity
|
|
@@ -146,8 +161,7 @@ class UserEntity < Grape::Entity
|
|
|
146
161
|
# The :audit_log association will only be preloaded if `include_audit_log` is true in the options.
|
|
147
162
|
expose :audit_log,
|
|
148
163
|
using: AuditLogEntity,
|
|
149
|
-
|
|
150
|
-
preload_condition: ->(options) { options[:include_audit_log] }
|
|
164
|
+
preload: [:audit_log, ->(options) { options[:include_audit_log] }]
|
|
151
165
|
end
|
|
152
166
|
|
|
153
167
|
# Preloading for :audit_log is skipped
|
|
@@ -157,6 +171,19 @@ UserEntity.represent(user)
|
|
|
157
171
|
UserEntity.represent(user, include_audit_log: true)
|
|
158
172
|
```
|
|
159
173
|
|
|
174
|
+
For a callback with a condition:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
class UserEntity < Grape::Entity
|
|
178
|
+
expose :stats,
|
|
179
|
+
using: UserStatsEntity,
|
|
180
|
+
preload: [
|
|
181
|
+
->(users, _options) { StatsService.batch_get(users) },
|
|
182
|
+
->(options) { options[:include_stats] }
|
|
183
|
+
]
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
160
187
|
## Development
|
|
161
188
|
|
|
162
189
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
@@ -9,9 +9,8 @@ module Grape
|
|
|
9
9
|
module ClassMethods # rubocop:disable Style/Documentation
|
|
10
10
|
def represent(objects, options = {})
|
|
11
11
|
options = Grape::Entity::Options.new(options) unless options.is_a?(Grape::Entity::Options)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
Preloader.with_disable { super(objects, options) }
|
|
12
|
+
Preloader.new(self, objects, options).call if Preloader.enabled?
|
|
13
|
+
super(objects, options)
|
|
15
14
|
end
|
|
16
15
|
end
|
|
17
16
|
end
|
|
@@ -21,5 +20,5 @@ end
|
|
|
21
20
|
|
|
22
21
|
Grape::Entity.prepend(Grape::Entity::Preloader::Entity)
|
|
23
22
|
silence_warnings do
|
|
24
|
-
Grape::Entity::OPTIONS = (Grape::Entity::OPTIONS + %i[
|
|
23
|
+
Grape::Entity::OPTIONS = (Grape::Entity::OPTIONS + %i[preload]).freeze
|
|
25
24
|
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grape
|
|
4
|
+
class Entity
|
|
5
|
+
class Preloader
|
|
6
|
+
module Exposure # rubocop:disable Style/Documentation
|
|
7
|
+
module Base # rubocop:disable Style/Documentation
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
attr_reader :preload
|
|
11
|
+
|
|
12
|
+
def initialize(_attribute, options, _conditions)
|
|
13
|
+
@preload = options[:preload]
|
|
14
|
+
validate_preload_option!
|
|
15
|
+
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def preload_association
|
|
20
|
+
case preload
|
|
21
|
+
when Symbol
|
|
22
|
+
preload
|
|
23
|
+
when Array
|
|
24
|
+
value = preload[0]
|
|
25
|
+
value.is_a?(Symbol) ? value : nil
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def preload_callback
|
|
30
|
+
case preload
|
|
31
|
+
when Proc
|
|
32
|
+
preload
|
|
33
|
+
when Array
|
|
34
|
+
value = preload[0]
|
|
35
|
+
value.is_a?(Proc) ? value : nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def preload_condition
|
|
40
|
+
preload.last if preload.is_a?(Array)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def validate_preload_option! # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/AbcSize,Metrics/PerceivedComplexity
|
|
46
|
+
normalized_preload = Array.wrap(preload)
|
|
47
|
+
return if normalized_preload.empty?
|
|
48
|
+
raise ArgumentError if normalized_preload.size > 2
|
|
49
|
+
|
|
50
|
+
first_value, second_value = normalized_preload
|
|
51
|
+
raise ArgumentError if !first_value.is_a?(Symbol) && !first_value.is_a?(Proc)
|
|
52
|
+
raise ArgumentError if first_value.is_a?(Proc) && first_value.arity != 2
|
|
53
|
+
raise ArgumentError if second_value.is_a?(Proc) && second_value.arity != 1
|
|
54
|
+
rescue ArgumentError
|
|
55
|
+
raise ArgumentError, <<~MSG.strip_heredoc
|
|
56
|
+
The :preload option must be a Symbol, Proc, or Array.
|
|
57
|
+
- Symbol: :activerecord_association_name
|
|
58
|
+
- Proc(callback): ->(objects, options) { { object1 => value1, object2 => value2 } }
|
|
59
|
+
objects: An array of the parent objects being represented.
|
|
60
|
+
options: The Grape::Entity::Options object for the current representation context.
|
|
61
|
+
return: A Hash mapping each object to its preloaded value.
|
|
62
|
+
- Array: [activerecord_association_name, condition_proc] | [callback_proc, condition_proc]
|
|
63
|
+
condition_proc: ->(options) { ... }
|
|
64
|
+
options: The Grape::Entity::Options object for the root representation context.
|
|
65
|
+
return: A truthy or falsy value indicating whether preloading should be performed.
|
|
66
|
+
|
|
67
|
+
eg:
|
|
68
|
+
preload: :books
|
|
69
|
+
preload: ->(objects, options) { ... }
|
|
70
|
+
preload: [:books, ->(options) { ... }]
|
|
71
|
+
preload: [->(objects, options) { ... }, ->(options) { ... }]
|
|
72
|
+
MSG
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
::Grape::Entity::Exposure::Base.prepend(Base)
|
|
76
|
+
|
|
77
|
+
module RepresentExposure # rubocop:disable Style/Documentation
|
|
78
|
+
def value(...)
|
|
79
|
+
Preloader.with_disable { super }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
::Grape::Entity::Exposure::RepresentExposure.prepend(RepresentExposure)
|
|
83
|
+
|
|
84
|
+
module Value # rubocop:disable Style/Documentation
|
|
85
|
+
def value(entity, options)
|
|
86
|
+
cache = options.dig(PRELOAD_CACHE_KEY, preload_callback)
|
|
87
|
+
cache.is_a?(Hash) ? cache[entity.object] : super
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Always return true for preloaded exposures since they are always valid
|
|
91
|
+
# The actual validation happens in the #value method
|
|
92
|
+
def valid?(entity)
|
|
93
|
+
preload_callback ? true : super
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
::Grape::Entity::Exposure::DelegatorExposure.prepend(Value)
|
|
97
|
+
::Grape::Entity::Exposure::BlockExposure.prepend(Value)
|
|
98
|
+
::Grape::Entity::Exposure::FormatterExposure.prepend(Value)
|
|
99
|
+
::Grape::Entity::Exposure::FormatterBlockExposure.prepend(Value)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -6,12 +6,32 @@ module Grape
|
|
|
6
6
|
module Options # rubocop:disable Style/Documentation
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
prepended do
|
|
10
10
|
def_delegators :opts_hash, :delete, :[]=
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
# Grape-Entity builds new Options objects via merge/reverse_merge (e.g.
|
|
14
|
+
# reverse_merge(collection: true) when representing an array). The
|
|
15
|
+
# default implementation copies opts_hash but drops @for_nesting_cache,
|
|
16
|
+
# so nested Options created during preloading (and their populated
|
|
17
|
+
# PRELOAD_CACHE_KEY caches) are lost before later serialization. Copy the
|
|
18
|
+
# cache so the same nested Options are reused.
|
|
19
|
+
def merge(...)
|
|
20
|
+
super.tap { |options| options.instance_variable_set(:@for_nesting_cache, @for_nesting_cache.dup) }
|
|
21
|
+
end
|
|
22
|
+
def reverse_merge(...) # rubocop:disable Layout/EmptyLineBetweenDefs
|
|
23
|
+
super.tap { |options| options.instance_variable_set(:@for_nesting_cache, @for_nesting_cache.dup) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def build_for_nesting(...)
|
|
29
|
+
# Clear preload cache for nested entities to avoid sharing cache across nesting levels
|
|
30
|
+
super.tap { |options| options.opts_hash[PRELOAD_CACHE_KEY] = {} }
|
|
31
|
+
end
|
|
12
32
|
end
|
|
13
33
|
end
|
|
14
34
|
end
|
|
15
35
|
end
|
|
16
36
|
|
|
17
|
-
Grape::Entity::Options.
|
|
37
|
+
Grape::Entity::Options.prepend(Grape::Entity::Preloader::Options)
|
|
@@ -4,12 +4,15 @@ require 'grape-entity'
|
|
|
4
4
|
require_relative 'preloader/version'
|
|
5
5
|
require_relative 'preloader/entity'
|
|
6
6
|
require_relative 'preloader/options'
|
|
7
|
-
require_relative 'preloader/exposure
|
|
7
|
+
require_relative 'preloader/exposure'
|
|
8
8
|
|
|
9
9
|
module Grape
|
|
10
10
|
class Entity
|
|
11
11
|
class Preloader # rubocop:disable Style/Documentation,Metrics/ClassLength
|
|
12
|
-
|
|
12
|
+
STATE_KEY = :grape_entity_preloader
|
|
13
|
+
PRELOAD_CACHE_KEY = :grape_entity_preload_cache
|
|
14
|
+
|
|
15
|
+
attr_reader :entity_class, :objects, :options, :associations, :callbacks, :nested_association_chain
|
|
13
16
|
|
|
14
17
|
singleton_class.attr_accessor :enabled
|
|
15
18
|
self.enabled = false
|
|
@@ -18,63 +21,45 @@ module Grape
|
|
|
18
21
|
self.enabled = true
|
|
19
22
|
end
|
|
20
23
|
|
|
24
|
+
def self.disabled!
|
|
25
|
+
self.enabled = false
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
def self.enabled?
|
|
22
|
-
if ActiveSupport::IsolatedExecutionState.key?(
|
|
23
|
-
ActiveSupport::IsolatedExecutionState[
|
|
29
|
+
if ActiveSupport::IsolatedExecutionState.key?(STATE_KEY)
|
|
30
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY]
|
|
24
31
|
else
|
|
25
32
|
enabled
|
|
26
33
|
end
|
|
27
34
|
end
|
|
28
35
|
|
|
29
|
-
def self.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
old_value = ActiveSupport::IsolatedExecutionState[:grape_entity_preloader]
|
|
33
|
-
ActiveSupport::IsolatedExecutionState[:grape_entity_preloader] = true
|
|
34
|
-
|
|
35
|
-
yield
|
|
36
|
-
|
|
37
|
-
if old_value.nil?
|
|
38
|
-
ActiveSupport::IsolatedExecutionState.delete(:grape_entity_preloader)
|
|
39
|
-
else
|
|
40
|
-
ActiveSupport::IsolatedExecutionState[:grape_entity_preloader] = old_value
|
|
41
|
-
end
|
|
36
|
+
def self.disabled?
|
|
37
|
+
!enabled?
|
|
42
38
|
end
|
|
43
39
|
|
|
44
|
-
def self.
|
|
45
|
-
|
|
40
|
+
def self.with_enable(&block)
|
|
41
|
+
enabled? ? yield : with_state(true, &block)
|
|
46
42
|
end
|
|
47
43
|
|
|
48
|
-
def self.
|
|
49
|
-
|
|
44
|
+
def self.with_disable(&block)
|
|
45
|
+
disabled? ? yield : with_state(false, &block)
|
|
50
46
|
end
|
|
51
47
|
|
|
52
|
-
def self.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
old_value = ActiveSupport::IsolatedExecutionState[:grape_entity_preloader]
|
|
56
|
-
ActiveSupport::IsolatedExecutionState[:grape_entity_preloader] = false
|
|
48
|
+
def self.with_state(value)
|
|
49
|
+
old_value = ActiveSupport::IsolatedExecutionState[STATE_KEY]
|
|
50
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY] = value
|
|
57
51
|
|
|
58
52
|
yield
|
|
59
|
-
|
|
53
|
+
ensure
|
|
60
54
|
if old_value.nil?
|
|
61
|
-
ActiveSupport::IsolatedExecutionState.delete(
|
|
55
|
+
ActiveSupport::IsolatedExecutionState.delete(STATE_KEY)
|
|
62
56
|
else
|
|
63
|
-
ActiveSupport::IsolatedExecutionState[
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def self.activerecord_gte_7_0?
|
|
68
|
-
unless defined?(ActiveRecord) && ActiveRecord.respond_to?(:version) && ActiveRecord.version >= Gem::Version.new('7.0')
|
|
69
|
-
warn 'ActiveRecord 7.0 or later is required for preload association'
|
|
70
|
-
return false
|
|
57
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY] = old_value
|
|
71
58
|
end
|
|
72
|
-
|
|
73
|
-
true
|
|
74
59
|
end
|
|
75
60
|
|
|
76
|
-
def initialize(
|
|
77
|
-
@
|
|
61
|
+
def initialize(entity_class, objects, options)
|
|
62
|
+
@entity_class = entity_class
|
|
78
63
|
@objects = Array.wrap(objects)
|
|
79
64
|
@options = options
|
|
80
65
|
|
|
@@ -86,7 +71,7 @@ module Grape
|
|
|
86
71
|
def call
|
|
87
72
|
return if objects.empty?
|
|
88
73
|
|
|
89
|
-
|
|
74
|
+
extract_preload_option(entity_class.root_exposures, options, associations, [entity_class])
|
|
90
75
|
execute_preload_associations
|
|
91
76
|
execute_preload_callbacks
|
|
92
77
|
end
|
|
@@ -94,72 +79,105 @@ module Grape
|
|
|
94
79
|
private
|
|
95
80
|
|
|
96
81
|
def execute_preload_associations
|
|
97
|
-
return unless Preloader.activerecord_gte_7_0?
|
|
98
82
|
return if associations.empty?
|
|
99
83
|
|
|
100
84
|
# TODO: Change ActiveRecord async query
|
|
101
85
|
ActiveRecord::Associations::Preloader.new(records: objects, associations: associations).call
|
|
86
|
+
rescue => e # rubocop:disable Style/RescueStandardError
|
|
87
|
+
if defined?(ActiveRecord) && ActiveRecord.respond_to?(:version) && ActiveRecord.version >= Gem::Version.new('7.0')
|
|
88
|
+
raise e
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
raise 'Preloading associations requires ActiveRecord >= 7.0'
|
|
102
92
|
end
|
|
103
93
|
|
|
104
|
-
def execute_preload_callbacks # rubocop:disable Metrics/AbcSize,Metrics/MethodLength,Metrics/CyclomaticComplexity
|
|
94
|
+
def execute_preload_callbacks # rubocop:disable Metrics/AbcSize,Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
105
95
|
callbacks.each do |association_chain, exposures_with_options|
|
|
106
96
|
association_objects = association_chain.inject(objects) do |items, association|
|
|
107
97
|
items.filter_map(&association).flatten(1)
|
|
108
98
|
end
|
|
109
99
|
next if association_objects.empty?
|
|
110
100
|
|
|
111
|
-
exposures_with_options.each do |
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
101
|
+
exposures_with_options.group_by { |exposure, _options| exposure.preload_callback }.each do |callback, group|
|
|
102
|
+
first_options = group.first[1]
|
|
103
|
+
callback_result = callback.call(association_objects, first_options)
|
|
104
|
+
unless callback_result.is_a?(Hash)
|
|
105
|
+
raise ArgumentError, 'The :preload callback must return a Hash mapping objects to their preloaded values.'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
(first_options[PRELOAD_CACHE_KEY] ||= {})[callback] = callback_result
|
|
109
|
+
|
|
110
|
+
group.each do |exposure, nested_options|
|
|
111
|
+
next unless exposure.is_a?(Grape::Entity::Exposure::RepresentExposure)
|
|
112
|
+
|
|
113
|
+
# Dynamic key are difficult to handle and little used, so skip preloading directly.
|
|
114
|
+
key = exposure.instance_variable_get(:@key)
|
|
115
|
+
if key.respond_to?(:call)
|
|
116
|
+
warn "#{entity_class}.#{exposure.attribute} has dynamic key, preloading is not supported"
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
Preloader.new(
|
|
121
|
+
exposure.using_class,
|
|
122
|
+
callback_result.values.flatten(1),
|
|
123
|
+
nesting_options_for(nested_options, key)
|
|
124
|
+
).call
|
|
125
|
+
end
|
|
121
126
|
end
|
|
122
127
|
end
|
|
123
128
|
end
|
|
124
129
|
|
|
125
|
-
def
|
|
126
|
-
exposures.each do |exposure|
|
|
130
|
+
def extract_preload_option(exposures, options, associations, visited_entity_classes = []) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength
|
|
131
|
+
exposures.each do |exposure| # rubocop:disable Metrics/BlockLength
|
|
132
|
+
key = exposure.instance_variable_get(:@key)
|
|
133
|
+
# Dynamic key or attr_path are difficult to handle and little used, so skip preloading directly.
|
|
134
|
+
if key.respond_to?(:call)
|
|
135
|
+
warn "#{entity_class}.#{exposure.attribute} has dynamic key, preloading is not supported"
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
if exposure.instance_variable_get(:@attr_path_proc).respond_to?(:call)
|
|
139
|
+
warn "#{entity_class}.#{exposure.attribute} has dynamic attr_path, preloading is not supported"
|
|
140
|
+
next
|
|
141
|
+
end
|
|
142
|
+
|
|
127
143
|
next unless exposure.should_return_key?(options)
|
|
128
144
|
next if exposure.preload_condition && !exposure.preload_condition.call(options)
|
|
129
145
|
|
|
130
146
|
if exposure.preload_callback
|
|
131
147
|
callbacks[nested_association_chain.dup] << [exposure, options]
|
|
132
|
-
elsif exposure.preload_association
|
|
148
|
+
elsif exposure.preload_association
|
|
133
149
|
associations[exposure.preload_association] ||= {}
|
|
134
150
|
end
|
|
135
151
|
|
|
136
|
-
# Dynamic keys are difficult to handle and less used, skipped directly
|
|
137
|
-
next if exposure_with_dynamic_key?(exposure)
|
|
138
|
-
|
|
139
152
|
if exposure.is_a?(Grape::Entity::Exposure::NestingExposure)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
options.with_attr_path(key) do
|
|
154
|
+
extract_preload_option(
|
|
155
|
+
exposure.nested_exposures,
|
|
156
|
+
nesting_options_for(options, key),
|
|
157
|
+
associations,
|
|
158
|
+
visited_entity_classes
|
|
159
|
+
)
|
|
160
|
+
end
|
|
145
161
|
elsif exposure.is_a?(Grape::Entity::Exposure::RepresentExposure) && associations[exposure.preload_association]
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
# Skip cyclic entity references to avoid infinite recursion during preload option extraction.
|
|
163
|
+
next if visited_entity_classes.include?(exposure.using_class)
|
|
164
|
+
|
|
165
|
+
options.with_attr_path(key) do
|
|
166
|
+
nested_association_chain.push(exposure.preload_association)
|
|
167
|
+
extract_preload_option(
|
|
168
|
+
exposure.using_class.root_exposures,
|
|
169
|
+
nesting_options_for(options, key),
|
|
170
|
+
associations[exposure.preload_association],
|
|
171
|
+
visited_entity_classes + [exposure.using_class]
|
|
172
|
+
)
|
|
173
|
+
nested_association_chain.pop
|
|
174
|
+
end
|
|
153
175
|
end
|
|
154
176
|
end
|
|
155
177
|
end
|
|
156
178
|
|
|
157
|
-
def
|
|
158
|
-
options.for_nesting(
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def exposure_with_dynamic_key?(exposure)
|
|
162
|
-
exposure.instance_variable_get(:@key).respond_to?(:call)
|
|
179
|
+
def nesting_options_for(options, key)
|
|
180
|
+
key ? options.for_nesting(key) : options
|
|
163
181
|
end
|
|
164
182
|
end
|
|
165
183
|
end
|
|
@@ -4,4 +4,231 @@ RSpec.describe Grape::Entity::Preloader do
|
|
|
4
4
|
it 'has a version number' do
|
|
5
5
|
expect(Grape::Entity::Preloader::VERSION).not_to be_nil
|
|
6
6
|
end
|
|
7
|
+
|
|
8
|
+
describe '.with_enable' do
|
|
9
|
+
it 'returns the value of the block' do
|
|
10
|
+
result = described_class.with_enable { 42 }
|
|
11
|
+
expect(result).to eq(42)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'returns the value of the block when already enabled' do
|
|
15
|
+
described_class.enabled!
|
|
16
|
+
result = described_class.with_enable { 'hello' }
|
|
17
|
+
expect(result).to eq('hello')
|
|
18
|
+
ensure
|
|
19
|
+
described_class.disabled!
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe '.with_disable' do
|
|
24
|
+
it 'returns the value of the block' do
|
|
25
|
+
result = described_class.with_disable { 42 }
|
|
26
|
+
expect(result).to eq(42)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'returns the value of the block when already disabled' do
|
|
30
|
+
described_class.disabled!
|
|
31
|
+
result = described_class.with_disable { 'hello' }
|
|
32
|
+
expect(result).to eq('hello')
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe 'nested exposure options' do
|
|
37
|
+
def entity_class(paths)
|
|
38
|
+
Class.new(Grape::Entity) do
|
|
39
|
+
expose :a
|
|
40
|
+
expose :embed do
|
|
41
|
+
expose :b do |obj, options|
|
|
42
|
+
paths << options[:attr_path].dup
|
|
43
|
+
obj[:b]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'keeps correct attr_path when preloader is disabled' do
|
|
50
|
+
paths = []
|
|
51
|
+
described_class.with_disable { entity_class(paths).represent({ a: 1, b: 2 }, serializable: true) }
|
|
52
|
+
expect(paths).to eq([%i[embed b]])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'keeps correct attr_path when preloader is enabled' do
|
|
56
|
+
paths = []
|
|
57
|
+
described_class.with_enable { entity_class(paths).represent({ a: 1, b: 2 }, serializable: true) }
|
|
58
|
+
expect(paths).to eq([%i[embed b]])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'provides the same attr_path regardless of preloader state' do
|
|
62
|
+
paths = { disabled: [], enabled: [] }
|
|
63
|
+
described_class.with_disable { entity_class(paths[:disabled]).represent({ a: 1, b: 2 }, serializable: true) }
|
|
64
|
+
described_class.with_enable { entity_class(paths[:enabled]).represent({ a: 1, b: 2 }, serializable: true) }
|
|
65
|
+
expect(paths[:disabled]).to eq(paths[:enabled])
|
|
66
|
+
expect(paths[:enabled]).to eq([%i[embed b]])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'keeps correct attr_path when as_json is called after serializable: false with arrays' do
|
|
70
|
+
paths = []
|
|
71
|
+
described_class.with_enable { entity_class(paths).represent([{ a: 1, b: 2 }], serializable: false).as_json }
|
|
72
|
+
expect(paths).to eq([%i[embed b]])
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
describe 'preload callback deduplication' do
|
|
77
|
+
it 'only calls the same callback once for multiple exposures' do
|
|
78
|
+
calls = []
|
|
79
|
+
item_class = Struct.new(:id)
|
|
80
|
+
|
|
81
|
+
callback = lambda do |objects, _options|
|
|
82
|
+
calls << objects
|
|
83
|
+
objects.to_h { |obj| [obj, { value: obj.id }] }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
child_entity = Class.new(Grape::Entity) do
|
|
87
|
+
expose :value
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
parent_entity = Class.new(Grape::Entity) do
|
|
91
|
+
expose :foo, using: child_entity, preload: callback do |obj, _options|
|
|
92
|
+
{ value: obj.id }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
expose :bar, using: child_entity, preload: callback do |obj, _options|
|
|
96
|
+
{ value: obj.id }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
objects = [item_class.new(1), item_class.new(2)]
|
|
101
|
+
described_class.with_enable { parent_entity.represent(objects) }
|
|
102
|
+
|
|
103
|
+
expect(calls.size).to eq(1)
|
|
104
|
+
expect(calls.first).to eq(objects)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
describe 'preload cache isolation across nesting levels' do
|
|
109
|
+
it 'does not share parent cache with nested cache for the same object and callback' do
|
|
110
|
+
item_class = Struct.new(:id, :child)
|
|
111
|
+
calls = []
|
|
112
|
+
callback = lambda do |objects, _options|
|
|
113
|
+
call_index = calls.size
|
|
114
|
+
calls.concat(objects)
|
|
115
|
+
objects.to_h { |obj| [obj, { call_index: call_index }] }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
child_entity = Class.new(Grape::Entity) do
|
|
119
|
+
expose :id
|
|
120
|
+
expose :meta, preload: callback
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
parent_entity = Class.new(Grape::Entity) do
|
|
124
|
+
expose :id
|
|
125
|
+
expose :meta, preload: callback
|
|
126
|
+
expose :child, using: child_entity, preload: ->(objects, _options) { objects.to_h { |obj| [obj, obj.child] } }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
parent = item_class.new(1, nil)
|
|
130
|
+
parent.child = parent
|
|
131
|
+
|
|
132
|
+
result = described_class.with_enable { parent_entity.represent(parent, serializable: true) }
|
|
133
|
+
|
|
134
|
+
expect(calls.size).to eq(2)
|
|
135
|
+
expect(result[:meta]).to eq({ call_index: 0 })
|
|
136
|
+
expect(result[:child][:meta]).to eq({ call_index: 1 })
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
describe 'circular entity references' do
|
|
141
|
+
it 'does not recurse infinitely when extracting preload options for mutually referencing entities' do
|
|
142
|
+
item_class = Struct.new(:id, :child, :parent)
|
|
143
|
+
|
|
144
|
+
child_entity = Class.new(Grape::Entity)
|
|
145
|
+
parent_entity = Class.new(Grape::Entity) do
|
|
146
|
+
expose :id
|
|
147
|
+
expose :child, using: child_entity, preload: :child
|
|
148
|
+
end
|
|
149
|
+
child_entity.class_eval do
|
|
150
|
+
expose :id
|
|
151
|
+
expose :parent, using: parent_entity, preload: :parent
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
parent = item_class.new(1, nil, nil)
|
|
155
|
+
child = item_class.new(2, nil, parent)
|
|
156
|
+
parent.child = child
|
|
157
|
+
|
|
158
|
+
options = Grape::Entity::Options.new({})
|
|
159
|
+
preloader = described_class.new(parent_entity, [parent], options)
|
|
160
|
+
|
|
161
|
+
expect do
|
|
162
|
+
preloader.send(
|
|
163
|
+
:extract_preload_option,
|
|
164
|
+
parent_entity.root_exposures,
|
|
165
|
+
options,
|
|
166
|
+
{},
|
|
167
|
+
[parent_entity]
|
|
168
|
+
)
|
|
169
|
+
end.not_to raise_error
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
describe 'deferred serialization with serializable: false' do
|
|
174
|
+
it 'does not preload nested exposures twice when as_json is called later' do
|
|
175
|
+
item_class = Struct.new(:id, :child)
|
|
176
|
+
calls = []
|
|
177
|
+
callback = lambda do |objects, _options|
|
|
178
|
+
call_index = calls.size
|
|
179
|
+
calls.concat(objects)
|
|
180
|
+
objects.to_h { |obj| [obj, { call_index: call_index }] }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
child_entity = Class.new(Grape::Entity) do
|
|
184
|
+
expose :id
|
|
185
|
+
expose :meta, preload: callback
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
parent_entity = Class.new(Grape::Entity) do
|
|
189
|
+
expose :id
|
|
190
|
+
expose :meta, preload: callback
|
|
191
|
+
expose :child, using: child_entity, preload: ->(objects, _options) { objects.to_h { |obj| [obj, obj.child] } }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
parent = item_class.new(1, nil)
|
|
195
|
+
parent.child = parent
|
|
196
|
+
|
|
197
|
+
result = described_class.with_enable { parent_entity.represent(parent, serializable: false).as_json }
|
|
198
|
+
|
|
199
|
+
expect(calls.size).to eq(2)
|
|
200
|
+
expect(result[:meta]).to eq({ call_index: 0 })
|
|
201
|
+
expect(result[:child][:meta]).to eq({ call_index: 1 })
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it 'keeps nested preload cache when as_json is called after serializable: false with arrays' do
|
|
205
|
+
item_class = Struct.new(:id, :child)
|
|
206
|
+
calls = []
|
|
207
|
+
callback = lambda do |objects, _options|
|
|
208
|
+
call_index = calls.size
|
|
209
|
+
calls.concat(objects)
|
|
210
|
+
objects.to_h { |obj| [obj, { call_index: call_index }] }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
child_entity = Class.new(Grape::Entity) do
|
|
214
|
+
expose :id
|
|
215
|
+
expose :meta, preload: callback
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
parent_entity = Class.new(Grape::Entity) do
|
|
219
|
+
expose :id
|
|
220
|
+
expose :meta, preload: callback
|
|
221
|
+
expose :child, using: child_entity, preload: ->(objects, _options) { objects.to_h { |obj| [obj, obj.child] } }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
parent = item_class.new(1, nil)
|
|
225
|
+
parent.child = parent
|
|
226
|
+
|
|
227
|
+
result = described_class.with_enable { parent_entity.represent([parent], serializable: false).as_json }
|
|
228
|
+
|
|
229
|
+
expect(calls.size).to eq(2)
|
|
230
|
+
expect(result.first[:meta]).to eq({ call_index: 0 })
|
|
231
|
+
expect(result.first[:child][:meta]).to eq({ call_index: 1 })
|
|
232
|
+
end
|
|
233
|
+
end
|
|
7
234
|
end
|
data/spec/n_plus_one_op_spec.rb
CHANGED
|
@@ -30,7 +30,7 @@ RSpec.describe 'N+1 operation' do # rubocop:disable RSpec/DescribeClass
|
|
|
30
30
|
expect(Grape::Entity::Preloader.enabled?).to be(true)
|
|
31
31
|
|
|
32
32
|
expect do
|
|
33
|
-
Book::Entity.represent(books,
|
|
33
|
+
Book::Entity.represent(books, only: [:tags_by_association])
|
|
34
34
|
end.to make_database_queries(count: 1)
|
|
35
35
|
.and make_database_queries(
|
|
36
36
|
count: 1,
|
|
@@ -43,7 +43,7 @@ RSpec.describe 'N+1 operation' do # rubocop:disable RSpec/DescribeClass
|
|
|
43
43
|
expect(Grape::Entity::Preloader.enabled?).to be(true)
|
|
44
44
|
|
|
45
45
|
expect do
|
|
46
|
-
Book::Entity.represent(books,
|
|
46
|
+
Book::Entity.represent(books, only: [:tags_by_association])
|
|
47
47
|
end.to make_database_queries(count: 1)
|
|
48
48
|
.and make_database_queries(
|
|
49
49
|
count: 1,
|
|
@@ -95,7 +95,7 @@ RSpec.describe 'N+1 operation' do # rubocop:disable RSpec/DescribeClass
|
|
|
95
95
|
it 'single' do
|
|
96
96
|
books = Book.all.load
|
|
97
97
|
expect do
|
|
98
|
-
Book::Entity.represent(books,
|
|
98
|
+
Book::Entity.represent(books, only: %i[tags_by_association])
|
|
99
99
|
end.to make_database_queries(count: 1)
|
|
100
100
|
.and make_database_queries(count: 1, matching: /book_tags_by_association/)
|
|
101
101
|
end
|
|
@@ -104,8 +104,7 @@ RSpec.describe 'N+1 operation' do # rubocop:disable RSpec/DescribeClass
|
|
|
104
104
|
expect do
|
|
105
105
|
User::Entity.represent(
|
|
106
106
|
users,
|
|
107
|
-
|
|
108
|
-
only: [books_by_association: %i[tags_by_association tags_by_callback]]
|
|
107
|
+
only: [{ books_by_association: %i[tags_by_association tags_by_callback] }]
|
|
109
108
|
)
|
|
110
109
|
end.to make_database_queries(count: 3)
|
|
111
110
|
.and make_database_queries(count: 1, matching: /user_books_by_association/)
|
|
@@ -113,9 +112,8 @@ RSpec.describe 'N+1 operation' do # rubocop:disable RSpec/DescribeClass
|
|
|
113
112
|
.and make_database_queries(count: 1, matching: /book_tags_by_callback/)
|
|
114
113
|
end
|
|
115
114
|
|
|
116
|
-
it 'same
|
|
115
|
+
it 'same preload (single association after nested association)' do
|
|
117
116
|
options = {
|
|
118
|
-
serializable: true,
|
|
119
117
|
expose_books_count_by_association: true,
|
|
120
118
|
only: %i[books_count_by_association]
|
|
121
119
|
}
|
|
@@ -139,7 +137,7 @@ RSpec.describe 'N+1 operation' do # rubocop:disable RSpec/DescribeClass
|
|
|
139
137
|
describe 'callback' do
|
|
140
138
|
it 'single' do
|
|
141
139
|
expect do
|
|
142
|
-
Book::Entity.represent(books,
|
|
140
|
+
Book::Entity.represent(books, only: %i[tags_by_callback])
|
|
143
141
|
end.to make_database_queries(count: 1)
|
|
144
142
|
.and make_database_queries(count: 1, matching: /book_tags_by_callback/)
|
|
145
143
|
end
|
|
@@ -148,8 +146,7 @@ RSpec.describe 'N+1 operation' do # rubocop:disable RSpec/DescribeClass
|
|
|
148
146
|
expect do
|
|
149
147
|
User::Entity.represent(
|
|
150
148
|
users,
|
|
151
|
-
|
|
152
|
-
only: [books_by_callback: %i[tags_by_association tags_by_callback]]
|
|
149
|
+
only: [{ books_by_callback: %i[tags_by_association tags_by_callback] }]
|
|
153
150
|
)
|
|
154
151
|
end.to make_database_queries(count: 3)
|
|
155
152
|
.and make_database_queries(count: 1, matching: /books_by_callback/)
|
|
@@ -163,7 +160,6 @@ RSpec.describe 'N+1 operation' do # rubocop:disable RSpec/DescribeClass
|
|
|
163
160
|
expect do
|
|
164
161
|
Book::Entity.represent(
|
|
165
162
|
books,
|
|
166
|
-
serializable: true,
|
|
167
163
|
expose_book_tags_count_by_association: true,
|
|
168
164
|
only: %i[tags_count_by_association]
|
|
169
165
|
)
|
|
@@ -183,5 +179,19 @@ RSpec.describe 'N+1 operation' do # rubocop:disable RSpec/DescribeClass
|
|
|
183
179
|
.and make_database_queries(count: 2, matching: /book_tags_by_association/)
|
|
184
180
|
end
|
|
185
181
|
end
|
|
182
|
+
|
|
183
|
+
describe 'deferred serialization' do
|
|
184
|
+
it 'preloads nested callbacks when as_json is called after serializable: false' do
|
|
185
|
+
expect do
|
|
186
|
+
User::Entity.represent(
|
|
187
|
+
users,
|
|
188
|
+
serializable: false,
|
|
189
|
+
only: [{ books_by_association: %i[tags_by_callback] }]
|
|
190
|
+
).as_json
|
|
191
|
+
end.to make_database_queries(count: 2)
|
|
192
|
+
.and make_database_queries(count: 1, matching: /user_books_by_association/)
|
|
193
|
+
.and make_database_queries(count: 1, matching: /book_tags_by_callback/)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
186
196
|
end
|
|
187
197
|
end
|
data/spec/support/models/book.rb
CHANGED
|
@@ -16,29 +16,29 @@ class Book < ApplicationRecord
|
|
|
16
16
|
|
|
17
17
|
entity do
|
|
18
18
|
expose :name
|
|
19
|
-
expose :tags_by_association, using: 'Tag::Entity',
|
|
20
|
-
expose :tags_by_callback, using: 'Tag::Entity',
|
|
19
|
+
expose :tags_by_association, using: 'Tag::Entity', preload: :tags_by_association
|
|
20
|
+
expose :tags_by_callback, using: 'Tag::Entity', preload: lambda { |objects, _options|
|
|
21
21
|
ActiveRecord::Associations::Preloader.new(
|
|
22
22
|
records: objects,
|
|
23
23
|
associations: :tags_by_callback
|
|
24
|
-
).call
|
|
24
|
+
).call
|
|
25
|
+
objects.to_h { |object| [object, object.tags_by_callback] }
|
|
25
26
|
}
|
|
26
|
-
expose :tags_count_by_association,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
ActiveRecord::Associations::Preloader.new(
|
|
31
|
-
records: objects,
|
|
32
|
-
associations: :tags_by_callback
|
|
33
|
-
).call.first.preloaded_records
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
def tags_count_by_association
|
|
27
|
+
expose :tags_count_by_association, preload: [
|
|
28
|
+
:tags_by_association,
|
|
29
|
+
->(options) { options[:expose_book_tags_count_by_association] }
|
|
30
|
+
] do |object, _options|
|
|
37
31
|
object.tags_by_association.size
|
|
38
32
|
end
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
expose :tags_count_by_callback, preload: [
|
|
34
|
+
lambda { |objects, _options|
|
|
35
|
+
ActiveRecord::Associations::Preloader.new(
|
|
36
|
+
records: objects,
|
|
37
|
+
associations: :tags_by_callback
|
|
38
|
+
).call
|
|
39
|
+
objects.to_h { |object| [object, object.tags_by_callback.size] }
|
|
40
|
+
},
|
|
41
|
+
->(options) { options[:expose_book_tags_count_by_callback] }
|
|
42
|
+
]
|
|
43
43
|
end
|
|
44
44
|
end
|
data/spec/support/models/user.rb
CHANGED
|
@@ -20,54 +20,54 @@ class User < ApplicationRecord
|
|
|
20
20
|
entity do
|
|
21
21
|
expose :name
|
|
22
22
|
|
|
23
|
-
expose :books_by_association, using: 'Book::Entity',
|
|
24
|
-
expose :books_by_callback, using: 'Book::Entity',
|
|
23
|
+
expose :books_by_association, using: 'Book::Entity', preload: :books_by_association
|
|
24
|
+
expose :books_by_callback, using: 'Book::Entity', preload: lambda { |objects, _options|
|
|
25
25
|
ActiveRecord::Associations::Preloader.new(
|
|
26
26
|
records: objects,
|
|
27
27
|
associations: :books_by_callback
|
|
28
|
-
).call
|
|
28
|
+
).call
|
|
29
|
+
objects.to_h { |object| [object, object.books_by_callback] }
|
|
29
30
|
}
|
|
30
|
-
expose :books_count_by_association,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
expose :books_count_by_association, preload: [
|
|
32
|
+
:books_by_association,
|
|
33
|
+
->(options) { options[:expose_books_count_by_association] }
|
|
34
|
+
] do |object, _options|
|
|
35
|
+
object.books_by_association.size
|
|
36
|
+
end
|
|
37
|
+
expose :books_count_by_callback, preload: [
|
|
38
|
+
lambda { |objects, _options|
|
|
39
|
+
ActiveRecord::Associations::Preloader.new(
|
|
40
|
+
records: objects,
|
|
41
|
+
associations: :books_by_callback
|
|
42
|
+
).call
|
|
43
|
+
objects.to_h { |object| [object, object.books_by_callback.size] }
|
|
44
|
+
},
|
|
45
|
+
->(options) { options[:expose_books_count_by_callback] }
|
|
46
|
+
]
|
|
39
47
|
|
|
40
|
-
expose :tags_by_association, using: 'Tag::Entity',
|
|
41
|
-
expose :tags_by_callback, using: 'Tag::Entity',
|
|
48
|
+
expose :tags_by_association, using: 'Tag::Entity', preload: :tags_by_association
|
|
49
|
+
expose :tags_by_callback, using: 'Tag::Entity', preload: lambda { |objects, _options|
|
|
42
50
|
ActiveRecord::Associations::Preloader.new(
|
|
43
51
|
records: objects,
|
|
44
52
|
associations: :tags_by_callback
|
|
45
|
-
).call
|
|
53
|
+
).call
|
|
54
|
+
objects.to_h { |object| [object, object.tags_by_callback] }
|
|
46
55
|
}
|
|
47
|
-
expose :tags_count_by_association,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
ActiveRecord::Associations::Preloader.new(
|
|
52
|
-
records: objects,
|
|
53
|
-
associations: :tags_by_callback
|
|
54
|
-
).call.first.preloaded_records
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
def books_count_by_association
|
|
58
|
-
object.books_by_association.size
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def books_count_by_callback
|
|
62
|
-
object.books_by_callback.size
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def tags_count_by_association
|
|
56
|
+
expose :tags_count_by_association, preload: [
|
|
57
|
+
:tags_by_association,
|
|
58
|
+
->(options) { options[:expose_user_tags_count_by_association] }
|
|
59
|
+
] do |object, _options|
|
|
66
60
|
object.tags_by_association.size
|
|
67
61
|
end
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
62
|
+
expose :tags_count_by_callback, preload: [
|
|
63
|
+
lambda { |objects, _options|
|
|
64
|
+
ActiveRecord::Associations::Preloader.new(
|
|
65
|
+
records: objects,
|
|
66
|
+
associations: :tags_by_callback
|
|
67
|
+
).call
|
|
68
|
+
objects.to_h { |object| [object, object.tags_by_callback.size] }
|
|
69
|
+
},
|
|
70
|
+
->(options) { options[:expose_user_tags_count_by_callback] }
|
|
71
|
+
]
|
|
72
72
|
end
|
|
73
73
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: grape-entity-preloader
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- OuYangJinTing
|
|
@@ -39,7 +39,7 @@ files:
|
|
|
39
39
|
- Rakefile
|
|
40
40
|
- lib/grape/entity/preloader.rb
|
|
41
41
|
- lib/grape/entity/preloader/entity.rb
|
|
42
|
-
- lib/grape/entity/preloader/exposure
|
|
42
|
+
- lib/grape/entity/preloader/exposure.rb
|
|
43
43
|
- lib/grape/entity/preloader/options.rb
|
|
44
44
|
- lib/grape/entity/preloader/version.rb
|
|
45
45
|
- sig/grape/entity/preloader.rbs
|
|
@@ -71,7 +71,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
71
71
|
- !ruby/object:Gem::Version
|
|
72
72
|
version: '0'
|
|
73
73
|
requirements: []
|
|
74
|
-
rubygems_version:
|
|
74
|
+
rubygems_version: 4.0.6
|
|
75
75
|
specification_version: 4
|
|
76
76
|
summary: Grape::Entity::Preloader allows preload associations and callbacks for avoiding
|
|
77
77
|
N+1 operations in Grape::Entity.
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Grape
|
|
4
|
-
class Entity
|
|
5
|
-
class Preloader
|
|
6
|
-
module Exposure
|
|
7
|
-
module Base # rubocop:disable Style/Documentation
|
|
8
|
-
extend ActiveSupport::Concern
|
|
9
|
-
|
|
10
|
-
attr_reader :preload_association, :preload_callback, :preload_condition
|
|
11
|
-
|
|
12
|
-
def initialize(_attribute, options, _conditions)
|
|
13
|
-
@preload_association = options[:preload_association]
|
|
14
|
-
@preload_callback = options[:preload_callback]
|
|
15
|
-
@preload_condition = options[:preload_condition]
|
|
16
|
-
validate_preload_options
|
|
17
|
-
|
|
18
|
-
super
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
private
|
|
22
|
-
|
|
23
|
-
def validate_preload_options # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength
|
|
24
|
-
if preload_association && !preload_association.is_a?(Symbol)
|
|
25
|
-
raise ArgumentError, 'The :preload_association option must be a Symbol.'
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
if preload_callback && (!preload_callback.is_a?(Proc) || preload_callback.arity != 2)
|
|
29
|
-
raise ArgumentError, 'The :preload_callback option must be a Proc with 2 arguments.'
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
if preload_condition && (!preload_condition.is_a?(Proc) || preload_condition.arity != 1)
|
|
33
|
-
raise ArgumentError, 'The :preload_condition option must be a Proc with 1 argument.'
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
return unless preload_association && preload_callback
|
|
37
|
-
|
|
38
|
-
raise ArgumentError, 'The :preload_association and :preload_callback options cannot be used together.'
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
Grape::Entity::Exposure::Base.prepend(Grape::Entity::Preloader::Exposure::Base)
|