grape-entity-preloader 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4435d853f0ac2438cbd9def7d195a78e4ce66dcfc0db4015b29f20c5102846b6
4
- data.tar.gz: 2bde5dbb73a91201040bc0e21da30507638c1b61e164e494a6187dacc1ab328d
3
+ metadata.gz: 671275e5b668d11b22c54c7475aa8eb9ec91836a4f35549ed954af02d1e21eda
4
+ data.tar.gz: edaeb202a4ca0b7d56fec26d6d3fe07ad01b39391096bdfc0f25b4d7fb6f6fd7
5
5
  SHA512:
6
- metadata.gz: 36b9c7316b3cb852024039a2db9ffe0c55215f49c60614b49c95a23a1bf307432b8e564c8c09cd3b56b71a4ed5f212e5fbd756e87675901407acb8acd5a8b642
7
- data.tar.gz: 8f9c9b1ed336765001069d34ab27e7b80e5a94d6a829ffd276ff501785ed15d4a8f43d9cdf762c6a79603487e16ddff00669e0a2222833c091e8ff8ebd7a6532
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
- ### `preload_association`
50
+ ### `preload`
51
51
 
52
- Use `preload_association` to preload ActiveRecord associations. This helps to avoid N+1 queries when an exposure represents an association.
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, preload_association: :books
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, preload_association: :tags
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, preload_association: :books
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
- ### `preload_callback`
94
+ #### Custom preloading callback
91
95
 
92
- For more complex scenarios that `preload_association` doesn't cover (e.g., loading data from other services, custom caching logic), you can use `preload_callback`.
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
- **The `Proc` should return an array of objects that will be used for the nested entity representation. These returned objects will then be passed to the preloader for that nested entity, allowing for further nested preloading.**
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, preload_callback: ->(users, _options) do
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
- user_ids = users.map(&:id)
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
- # The preloader needs to associate the loaded data back to the original objects.
117
- # A common pattern is to attach the data to a new attribute on the object.
118
- users.each { |user| user.instance_variable_set(:@stats, stats_data[user.id]) }
119
-
120
- # The block must return the objects that will be presented by the nested entity.
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
- # In the entity, you need to define how to access the preloaded data.
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
- expose :stats, using: UserStatsEntity, preload_callback: ... do |user, _options|
130
- user.instance_variable_get(:@stats)
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
- ### `preload_condition`
150
+ #### Conditional preloading
136
151
 
137
- Use `preload_condition` to conditionally enable or disable preloading for an exposure. It must be a `Proc` that accepts one argument: `options`, which is the `Grape::Entity::Options` object.
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
- preload_association: :audit_log,
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
- Preloader.new(root_exposures, objects, options).call if Preloader.enabled?
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[preload_association preload_callback preload_condition]).freeze
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
- included do
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.include(Grape::Entity::Preloader::Options)
37
+ Grape::Entity::Options.prepend(Grape::Entity::Preloader::Options)
@@ -3,7 +3,7 @@
3
3
  module Grape
4
4
  class Entity
5
5
  class Preloader
6
- VERSION = '0.3.0'
6
+ VERSION = '1.0.0'
7
7
  end
8
8
  end
9
9
  end
@@ -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/base'
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
- attr_reader :exposures, :objects, :options, :associations, :callbacks, :nested_association_chain
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,67 +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?(:grape_entity_preloader)
23
- ActiveSupport::IsolatedExecutionState[:grape_entity_preloader]
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.with_enable # rubocop:disable Metrics/MethodLength
30
- return yield if enabled?
31
-
32
- begin
33
- old_value = ActiveSupport::IsolatedExecutionState[:grape_entity_preloader]
34
- ActiveSupport::IsolatedExecutionState[:grape_entity_preloader] = true
35
-
36
- yield
37
- ensure
38
- if old_value.nil?
39
- ActiveSupport::IsolatedExecutionState.delete(:grape_entity_preloader)
40
- else
41
- ActiveSupport::IsolatedExecutionState[:grape_entity_preloader] = old_value
42
- end
43
- end
44
- end
45
-
46
- def self.disabled!
47
- self.enabled = false
48
- end
49
-
50
36
  def self.disabled?
51
37
  !enabled?
52
38
  end
53
39
 
54
- def self.with_disable # rubocop:disable Metrics/MethodLength
55
- return yield if disabled?
56
-
57
- begin
58
- old_value = ActiveSupport::IsolatedExecutionState[:grape_entity_preloader]
59
- ActiveSupport::IsolatedExecutionState[:grape_entity_preloader] = false
40
+ def self.with_enable(&block)
41
+ enabled? ? yield : with_state(true, &block)
42
+ end
60
43
 
61
- yield
62
- ensure
63
- if old_value.nil?
64
- ActiveSupport::IsolatedExecutionState.delete(:grape_entity_preloader)
65
- else
66
- ActiveSupport::IsolatedExecutionState[:grape_entity_preloader] = old_value
67
- end
68
- end
44
+ def self.with_disable(&block)
45
+ disabled? ? yield : with_state(false, &block)
69
46
  end
70
47
 
71
- def self.activerecord_gte_7_0?
72
- unless defined?(ActiveRecord) && ActiveRecord.respond_to?(:version) && ActiveRecord.version >= Gem::Version.new('7.0')
73
- warn 'ActiveRecord 7.0 or later is required for preload association'
74
- return false
75
- end
48
+ def self.with_state(value)
49
+ old_value = ActiveSupport::IsolatedExecutionState[STATE_KEY]
50
+ ActiveSupport::IsolatedExecutionState[STATE_KEY] = value
76
51
 
77
- true
52
+ yield
53
+ ensure
54
+ if old_value.nil?
55
+ ActiveSupport::IsolatedExecutionState.delete(STATE_KEY)
56
+ else
57
+ ActiveSupport::IsolatedExecutionState[STATE_KEY] = old_value
58
+ end
78
59
  end
79
60
 
80
- def initialize(exposures, objects, options)
81
- @exposures = exposures
61
+ def initialize(entity_class, objects, options)
62
+ @entity_class = entity_class
82
63
  @objects = Array.wrap(objects)
83
64
  @options = options
84
65
 
@@ -90,7 +71,7 @@ module Grape
90
71
  def call
91
72
  return if objects.empty?
92
73
 
93
- extract_preload_options(exposures, options, associations)
74
+ extract_preload_option(entity_class.root_exposures, options, associations, [entity_class])
94
75
  execute_preload_associations
95
76
  execute_preload_callbacks
96
77
  end
@@ -98,72 +79,105 @@ module Grape
98
79
  private
99
80
 
100
81
  def execute_preload_associations
101
- return unless Preloader.activerecord_gte_7_0?
102
82
  return if associations.empty?
103
83
 
104
84
  # TODO: Change ActiveRecord async query
105
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'
106
92
  end
107
93
 
108
- 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
109
95
  callbacks.each do |association_chain, exposures_with_options|
110
96
  association_objects = association_chain.inject(objects) do |items, association|
111
97
  items.filter_map(&association).flatten(1)
112
98
  end
113
99
  next if association_objects.empty?
114
100
 
115
- exposures_with_options.each do |exposure, options|
116
- callback_objects = exposure.preload_callback.call(association_objects, options)
117
- # Dynamic keys are difficult to handle and less used, skipped directly
118
- next if !exposure.is_a?(Grape::Entity::Exposure::RepresentExposure) || exposure_with_dynamic_key?(exposure)
119
-
120
- Preloader.new(
121
- exposure.using_class.root_exposures,
122
- callback_objects,
123
- nesting_options(exposure, options)
124
- ).call
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
125
126
  end
126
127
  end
127
128
  end
128
129
 
129
- def extract_preload_options(exposures, options, associations) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength
130
- 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
+
131
143
  next unless exposure.should_return_key?(options)
132
144
  next if exposure.preload_condition && !exposure.preload_condition.call(options)
133
145
 
134
146
  if exposure.preload_callback
135
147
  callbacks[nested_association_chain.dup] << [exposure, options]
136
- elsif exposure.preload_association && Preloader.activerecord_gte_7_0?
148
+ elsif exposure.preload_association
137
149
  associations[exposure.preload_association] ||= {}
138
150
  end
139
151
 
140
- # Dynamic keys are difficult to handle and less used, skipped directly
141
- next if exposure_with_dynamic_key?(exposure)
142
-
143
152
  if exposure.is_a?(Grape::Entity::Exposure::NestingExposure)
144
- extract_preload_options(
145
- exposure.nested_exposures,
146
- nesting_options(exposure, options),
147
- associations
148
- )
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
149
161
  elsif exposure.is_a?(Grape::Entity::Exposure::RepresentExposure) && associations[exposure.preload_association]
150
- nested_association_chain.push(exposure.preload_association)
151
- extract_preload_options(
152
- exposure.using_class.root_exposures,
153
- nesting_options(exposure, options),
154
- associations[exposure.preload_association]
155
- )
156
- nested_association_chain.pop
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
157
175
  end
158
176
  end
159
177
  end
160
178
 
161
- def nesting_options(exposure, options)
162
- options.for_nesting(exposure.instance_variable_get(:@key))
163
- end
164
-
165
- def exposure_with_dynamic_key?(exposure)
166
- exposure.instance_variable_get(:@key).respond_to?(:call)
179
+ def nesting_options_for(options, key)
180
+ key ? options.for_nesting(key) : options
167
181
  end
168
182
  end
169
183
  end
@@ -32,4 +32,203 @@ RSpec.describe Grape::Entity::Preloader do
32
32
  expect(result).to eq('hello')
33
33
  end
34
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
35
234
  end
@@ -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, serializable: true, only: [:tags_by_association])
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, serializable: true, only: [:tags_by_association])
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, serializable: true, only: %i[tags_by_association])
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,7 +104,6 @@ RSpec.describe 'N+1 operation' do # rubocop:disable RSpec/DescribeClass
104
104
  expect do
105
105
  User::Entity.represent(
106
106
  users,
107
- serializable: true,
108
107
  only: [{ books_by_association: %i[tags_by_association tags_by_callback] }]
109
108
  )
110
109
  end.to make_database_queries(count: 3)
@@ -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 preload_association(single preload_association after nested preload_association)' do
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, serializable: true, only: %i[tags_by_callback])
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,7 +146,6 @@ RSpec.describe 'N+1 operation' do # rubocop:disable RSpec/DescribeClass
148
146
  expect do
149
147
  User::Entity.represent(
150
148
  users,
151
- serializable: true,
152
149
  only: [{ books_by_callback: %i[tags_by_association tags_by_callback] }]
153
150
  )
154
151
  end.to make_database_queries(count: 3)
@@ -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
@@ -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', preload_association: :tags_by_association
20
- expose :tags_by_callback, using: 'Tag::Entity', preload_callback: lambda { |objects, _options|
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.first.preloaded_records
24
+ ).call
25
+ objects.to_h { |object| [object, object.tags_by_callback] }
25
26
  }
26
- expose :tags_count_by_association, preload_association: :tags_by_association,
27
- preload_condition: ->(options) { options[:expose_book_tags_count_by_association] } # rubocop:disable Layout/LineLength
28
- expose :tags_count_by_callback, preload_condition: ->(options) { options[:expose_book_tags_count_by_callback] },
29
- preload_callback: lambda { |objects, _options|
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
- def tags_count_by_callback
41
- object.tags_by_callback.size
42
- end
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
@@ -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', preload_association: :books_by_association
24
- expose :books_by_callback, using: 'Book::Entity', preload_callback: lambda { |objects, _options|
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.first.preloaded_records
28
+ ).call
29
+ objects.to_h { |object| [object, object.books_by_callback] }
29
30
  }
30
- expose :books_count_by_association, preload_association: :books_by_association,
31
- preload_condition: ->(options) { options[:expose_books_count_by_association] }
32
- expose :books_count_by_callback, preload_condition: ->(options) { options[:expose_books_count_by_callback] },
33
- preload_callback: lambda { |objects, _options|
34
- ActiveRecord::Associations::Preloader.new(
35
- records: objects,
36
- associations: :books_by_callback
37
- ).call.first.preloaded_records
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', preload_association: :tags_by_association
41
- expose :tags_by_callback, using: 'Tag::Entity', preload_callback: lambda { |objects, _options|
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.first.preloaded_records
53
+ ).call
54
+ objects.to_h { |object| [object, object.tags_by_callback] }
46
55
  }
47
- expose :tags_count_by_association, preload_association: :tags_by_association,
48
- preload_condition: ->(options) { options[:expose_user_tags_count_by_association] } # rubocop:disable Layout/LineLength
49
- expose :tags_count_by_callback, preload_condition: ->(options) { options[:expose_user_tags_count_by_callback] },
50
- preload_callback: lambda { |objects, _options|
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
- def tags_count_by_callback
70
- object.tags_by_callback.size
71
- end
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.3.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/base.rb
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: 3.6.7
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)