graphiti 2.0.0.beta.9 → 2.0.0.beta.11

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +2 -0
  3. data/.tool-versions +1 -1
  4. data/CHANGELOG.md +46 -0
  5. data/Rakefile +17 -12
  6. data/lib/generators/graphiti/generator_mixin.rb +24 -0
  7. data/lib/generators/graphiti/templates/application_resource.rb.erb +9 -16
  8. data/lib/graphiti/adapters/abstract.rb +4 -3
  9. data/lib/graphiti/configuration.rb +55 -24
  10. data/lib/graphiti/delegates/pagination.rb +2 -2
  11. data/lib/graphiti/errors.rb +35 -3
  12. data/lib/graphiti/query.rb +61 -13
  13. data/lib/graphiti/rails/controller.rb +26 -6
  14. data/lib/graphiti/rails/rake_helpers.rb +17 -0
  15. data/lib/graphiti/renderer.rb +4 -1
  16. data/lib/graphiti/request_validators/update_validator.rb +1 -1
  17. data/lib/graphiti/request_validators/validator.rb +9 -0
  18. data/lib/graphiti/resource/configuration.rb +139 -40
  19. data/lib/graphiti/resource/dsl.rb +21 -2
  20. data/lib/graphiti/resource/interface.rb +1 -1
  21. data/lib/graphiti/resource/links.rb +39 -5
  22. data/lib/graphiti/resource/remote.rb +2 -1
  23. data/lib/graphiti/resource/sideloading.rb +2 -2
  24. data/lib/graphiti/schema.rb +2 -2
  25. data/lib/graphiti/scope.rb +100 -26
  26. data/lib/graphiti/scoping/filter.rb +4 -4
  27. data/lib/graphiti/scoping/paginate.rb +3 -3
  28. data/lib/graphiti/serializer.rb +30 -8
  29. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +9 -9
  30. data/lib/graphiti/sideload.rb +50 -33
  31. data/lib/graphiti/spec_helpers/matchers.rb +2 -2
  32. data/lib/graphiti/spec_helpers/rspec.rb +6 -3
  33. data/lib/graphiti/util/link.rb +1 -1
  34. data/lib/graphiti/util/serializer_attributes.rb +5 -3
  35. data/lib/graphiti/util/serializer_relationships.rb +16 -15
  36. data/lib/graphiti/version.rb +1 -1
  37. data/lib/graphiti.rb +5 -0
  38. data/lib/tasks/graphiti.rake +4 -0
  39. data/package.json +1 -1
  40. metadata +1 -1
@@ -4,16 +4,76 @@ module Graphiti
4
4
  extend ActiveSupport::Concern
5
5
 
6
6
  DEFAULT_MAX_PAGE_SIZE = 1_000
7
+ LINK_MODES = [true, false, :on_demand].freeze
8
+ BELONGS_TO_RESOURCE_IDS_MODES = [:foreign_key, :always, :never].freeze
9
+ BLANK_MODES = [:literal, :null, :rejected].freeze
10
+
11
+ # Grouped for the ApplicationResource the install generator writes.
12
+ SETTING_GROUPS = { # :nodoc:
13
+ attributes: {
14
+ attributes_readable_by_default: {default: true},
15
+ attributes_writable_by_default: {default: true},
16
+ attributes_sortable_by_default: {default: true},
17
+ attributes_filterable_by_default: {default: true},
18
+ attributes_schema_by_default: {default: true},
19
+ typecast_reads: {default: true}
20
+ },
21
+ relationships: {
22
+ relationships_readable_by_default: {default: true},
23
+ relationships_writable_by_default: {default: true},
24
+ relationship_placeholders: {default: false, note: "render placeholders for relationships with no ids and no link"},
25
+ belongs_to_resource_ids_by_default: {
26
+ default: :foreign_key,
27
+ values: BELONGS_TO_RESOURCE_IDS_MODES,
28
+ invalid: ->(klass, value) { Errors::InvalidBelongsToResourceIds.new(klass, value) }
29
+ }
30
+ },
31
+ filters: {
32
+ filter_blanks_treated_as: {
33
+ default: :literal,
34
+ values: BLANK_MODES,
35
+ invalid: ->(klass, value) { Errors::InvalidFilterBlanks.new(klass, :filter_blanks_treated_as, value) }
36
+ }
37
+ },
38
+ sorting: {
39
+ default_sort: {default: nil, note: "per resource, e.g. [{id: :desc}]"}
40
+ },
41
+ pagination: {
42
+ page_default_size: {default: nil, note: "unset falls back to 20"},
43
+ page_max_size: {default: DEFAULT_MAX_PAGE_SIZE, format: "1_000"},
44
+ page_cursors: {default: false, note: "render cursors for pagination"},
45
+ page_links: {
46
+ default: false,
47
+ values: LINK_MODES,
48
+ invalid: ->(klass, value) { Errors::InvalidLinkRendering.new(klass, :page_links, value) }
49
+ }
50
+ },
51
+ endpoints: {
52
+ validate_requests: {default: true, note: "refuse requests to undeclared endpoints"}
53
+ },
54
+ links: {
55
+ relationship_links: {
56
+ default: true,
57
+ values: LINK_MODES,
58
+ invalid: ->(klass, value) { Errors::InvalidLinkRendering.new(klass, :relationship_links, value) }
59
+ },
60
+ validate_links: {default: true, note: "refuse to render links to unroutable endpoints"}
61
+ }
62
+ }.freeze
63
+
64
+ SETTINGS = SETTING_GROUPS.values.reduce(:merge).freeze # :nodoc:
7
65
 
8
66
  module Overrides
9
- BELONGS_TO_RESOURCE_IDS_VALUES = [:foreign_key, :always, :never].freeze
67
+ SETTINGS.each_pair do |name, setting|
68
+ next unless setting[:values]
10
69
 
11
- def belongs_to_resource_ids_by_default=(val)
12
- unless BELONGS_TO_RESOURCE_IDS_VALUES.include?(val)
13
- raise Errors::InvalidBelongsToResourceIds.new(self, val)
14
- end
70
+ define_method(:"#{name}=") do |val|
71
+ unless setting[:values].include?(val)
72
+ raise setting[:invalid].call(self, val)
73
+ end
15
74
 
16
- super
75
+ super(val)
76
+ end
17
77
  end
18
78
 
19
79
  def serializer=(val)
@@ -95,51 +155,37 @@ module Graphiti
95
155
  :polymorphic,
96
156
  :polymorphic_child,
97
157
  :serializer,
98
- :default_page_size,
99
- :default_sort,
100
- :max_page_size,
101
- :attributes_readable_by_default,
102
- :attributes_writable_by_default,
103
- :attributes_sortable_by_default,
104
- :attributes_filterable_by_default,
105
- :attributes_schema_by_default,
106
- :relationships_readable_by_default,
107
- :relationships_writable_by_default,
108
- :belongs_to_resource_ids_by_default,
109
- :filters_accept_nil_by_default,
110
- :filters_deny_empty_by_default,
111
158
  :graphql_entrypoint,
112
- :cursor_paginatable
159
+ *SETTINGS.keys
113
160
 
114
161
  class << self
115
162
  prepend Overrides
116
163
  end
117
164
 
165
+ SETTINGS.each_pair do |name, setting|
166
+ public_send(:"#{name}=", setting[:default])
167
+ end
168
+
118
169
  def self.inherited(klass)
119
170
  super
120
171
  klass.config = Util::Hash.deep_dup(config)
121
172
  klass.adapter ||= Adapters::Abstract
122
- klass.max_page_size ||= DEFAULT_MAX_PAGE_SIZE
123
173
  # re-assigning causes a new Class.new
124
174
  klass.serializer = (klass.serializer || klass.infer_serializer_superclass)
125
175
  klass.type ||= klass.infer_type
126
176
  klass.graphql_entrypoint = klass.type.to_s.pluralize.to_sym
127
- default(klass, :attributes_readable_by_default, true)
128
- default(klass, :attributes_writable_by_default, true)
129
- default(klass, :attributes_sortable_by_default, true)
130
- default(klass, :attributes_filterable_by_default, true)
131
- default(klass, :attributes_schema_by_default, true)
132
- default(klass, :relationships_readable_by_default, true)
133
- default(klass, :relationships_writable_by_default, true)
134
- default(klass, :belongs_to_resource_ids_by_default, :foreign_key)
135
- default(klass, :filters_accept_nil_by_default, false)
136
- default(klass, :filters_deny_empty_by_default, false)
137
-
138
177
  unless klass.config[:attributes][:id]
139
178
  klass.attribute :id, :integer_id
140
179
  end
141
180
  klass.stat total: [:count]
142
181
 
182
+ # An abstract parent has no serializer for its sideloads, so the subclass applies them here.
183
+ if abstract_class?
184
+ klass.config[:sideloads].each_pair do |name, sideload|
185
+ klass.apply_sideload_to_serializer(name) if klass.eagerly_apply_sideload?(sideload)
186
+ end
187
+ end
188
+
143
189
  if defined?(::Rails) && ::Rails.env.development?
144
190
  # Avoid adding dupe resources when re-autoloading
145
191
  Graphiti.resources.reject! { |r| r.name == klass.name }
@@ -149,6 +195,52 @@ module Graphiti
149
195
  end
150
196
 
151
197
  class_methods do
198
+ # Deprecated. Both folded into filter_blanks_treated_as. Remove in 3.0.
199
+ def filters_accept_nil_by_default
200
+ filter_blanks_treated_as == :null
201
+ end
202
+
203
+ def filters_accept_nil_by_default=(val)
204
+ self.filter_blanks_treated_as = val ? :null : :literal
205
+ end
206
+
207
+ def filters_deny_empty_by_default
208
+ filter_blanks_treated_as == :rejected
209
+ end
210
+
211
+ def filters_deny_empty_by_default=(val)
212
+ self.filter_blanks_treated_as = val ? :rejected : :literal
213
+ end
214
+
215
+ # Deprecated. Renamed to the page_ family. Remove in 3.0.
216
+ def default_page_size
217
+ page_default_size
218
+ end
219
+
220
+ def default_page_size=(val)
221
+ self.page_default_size = val
222
+ end
223
+
224
+ def max_page_size
225
+ page_max_size
226
+ end
227
+
228
+ def max_page_size=(val)
229
+ self.page_max_size = val
230
+ end
231
+
232
+ def cursor_paginatable
233
+ page_cursors
234
+ end
235
+
236
+ def cursor_paginatable=(val)
237
+ self.page_cursors = val
238
+ end
239
+
240
+ def cursor_paginatable?
241
+ !!page_cursors
242
+ end
243
+
152
244
  def get_attr!(name, flag, opts = {})
153
245
  opts[:raise_error] = true
154
246
  get_attr(name, flag, opts)
@@ -205,14 +297,6 @@ module Graphiti
205
297
  serializer_class
206
298
  end
207
299
 
208
- def default(object, attr, value)
209
- prior = object.send(attr)
210
- unless prior || prior == false
211
- object.send(:"#{attr}=", value)
212
- end
213
- end
214
- private :default
215
-
216
300
  def config
217
301
  @config ||=
218
302
  {
@@ -332,5 +416,20 @@ module Graphiti
332
416
  self.class.default_filters
333
417
  end
334
418
  end
419
+
420
+ blanks_msg = "Use `self.filter_blanks_treated_as` (:literal, :null, or :rejected)"
421
+ page_msg = "Use `self.page_default_size`, `self.page_max_size` and `self.page_cursors`"
422
+ DEPRECATOR.deprecate_methods(Configuration::ClassMethods,
423
+ filters_accept_nil_by_default: blanks_msg,
424
+ "filters_accept_nil_by_default=": blanks_msg,
425
+ filters_deny_empty_by_default: blanks_msg,
426
+ "filters_deny_empty_by_default=": blanks_msg,
427
+ default_page_size: page_msg,
428
+ "default_page_size=": page_msg,
429
+ max_page_size: page_msg,
430
+ "max_page_size=": page_msg,
431
+ cursor_paginatable: page_msg,
432
+ "cursor_paginatable=": page_msg,
433
+ cursor_paginatable?: page_msg)
335
434
  end
336
435
  end
@@ -40,8 +40,7 @@ module Graphiti
40
40
  required: required,
41
41
  schema: schema,
42
42
  operators: operators.to_hash,
43
- allow_nil: opts.fetch(:allow_nil, filters_accept_nil_by_default),
44
- deny_empty: opts.fetch(:deny_empty, filters_deny_empty_by_default)
43
+ blanks: blanks_for(name, opts)
45
44
  }
46
45
  elsif (type = args[0])
47
46
  attribute name, type, only: [:filterable], allow: opts[:allow]
@@ -206,6 +205,26 @@ module Graphiti
206
205
  end
207
206
  end
208
207
  private :relationship_option
208
+
209
+ def blanks_for(name, opts)
210
+ if opts.key?(:deny_empty)
211
+ DEPRECATOR.warn("The deny_empty: filter option is deprecated. Use blanks: :rejected.")
212
+ return :rejected if opts[:deny_empty]
213
+ end
214
+
215
+ if opts.key?(:allow_nil)
216
+ DEPRECATOR.warn("The allow_nil: filter option is deprecated. Use blanks: :null.")
217
+ return opts[:allow_nil] ? :null : :literal
218
+ end
219
+
220
+ blanks = opts.fetch(:blanks, filter_blanks_treated_as)
221
+ unless Resource::BLANK_MODES.include?(blanks)
222
+ raise Errors::InvalidFilterBlanks.new(self, name, blanks)
223
+ end
224
+
225
+ blanks
226
+ end
227
+ private :blanks_for
209
228
  end
210
229
  end
211
230
  end
@@ -80,7 +80,7 @@ module Graphiti
80
80
  end
81
81
 
82
82
  def validate_request!(params)
83
- return if Graphiti.context[:graphql] || !validate_endpoints?
83
+ return if Graphiti.context[:graphql] || !validate_requests?
84
84
 
85
85
  if context&.respond_to?(:request)
86
86
  path = context.request.env["PATH_INFO"]
@@ -25,12 +25,8 @@ module Graphiti
25
25
  class_attribute :endpoint,
26
26
  :base_url,
27
27
  :endpoint_namespace,
28
- :secondary_endpoints,
29
- :autolink,
30
- :validate_endpoints
28
+ :secondary_endpoints
31
29
  self.secondary_endpoints = []
32
- self.autolink = true
33
- self.validate_endpoints = true
34
30
 
35
31
  class << self
36
32
  prepend Overrides
@@ -38,6 +34,36 @@ module Graphiti
38
34
  end
39
35
 
40
36
  class_methods do
37
+ # Deprecated. Folded into relationship_links. Remove in 3.0.
38
+ def autolink
39
+ relationship_links != false
40
+ end
41
+
42
+ # true cannot express :on_demand, so it leaves an inherited on-demand mode alone.
43
+ def autolink=(val)
44
+ return if val && relationship_links == :on_demand
45
+
46
+ self.relationship_links = val ? true : false
47
+ end
48
+
49
+ def autolink?
50
+ autolink
51
+ end
52
+
53
+ # Deprecated. Split into validate_requests and validate_links. Remove in 3.0.
54
+ def validate_endpoints
55
+ validate_requests && validate_links
56
+ end
57
+
58
+ def validate_endpoints=(val)
59
+ self.validate_requests = val
60
+ self.validate_links = val
61
+ end
62
+
63
+ def validate_endpoints?
64
+ validate_endpoints
65
+ end
66
+
41
67
  def infer_endpoint
42
68
  return unless name
43
69
 
@@ -101,4 +127,12 @@ module Graphiti
101
127
  end
102
128
  end
103
129
  end
130
+
131
+ msg = "Use `self.relationship_links` (true, false, or :on_demand)"
132
+ endpoints_msg = "Use `self.validate_requests` for inbound requests, `self.validate_links` for rendered links"
133
+ DEPRECATOR.deprecate_methods(Links::ClassMethods,
134
+ autolink: msg, "autolink=": msg, autolink?: msg,
135
+ validate_endpoints: endpoints_msg,
136
+ "validate_endpoints=": endpoints_msg,
137
+ validate_endpoints?: endpoints_msg)
104
138
  end
@@ -6,7 +6,8 @@ module Graphiti
6
6
  included do
7
7
  self.adapter = Graphiti::Adapters::GraphitiAPI
8
8
  self.model = OpenStruct
9
- self.validate_endpoints = false
9
+ self.validate_requests = false
10
+ self.validate_links = false
10
11
 
11
12
  class_attribute :timeout,
12
13
  :open_timeout
@@ -126,11 +126,11 @@ module Graphiti
126
126
  end
127
127
 
128
128
  # If eager loading, ensure routes are loaded first, then apply
129
- # This happens in Railtie
129
+ # This happens in Railtie. Setup runs once, so classes redefined by a reload apply at definition.
130
130
  def eagerly_apply_sideload?(sideload)
131
131
  # TODO: Maybe handle this in the Rails integration
132
132
  if defined?(::Rails) && (app = ::Rails.application)
133
- app.config.eager_load ? false : true
133
+ app.config.eager_load ? Graphiti.setup? : true
134
134
  else
135
135
  sideload.resource_class_loaded?
136
136
  end
@@ -111,8 +111,8 @@ module Graphiti
111
111
  config[:default_sort] = default_sort
112
112
  end
113
113
 
114
- if r.default_page_size
115
- config[:default_page_size] = r.default_page_size
114
+ if r.page_default_size
115
+ config[:default_page_size] = r.page_default_size
116
116
  end
117
117
 
118
118
  if r.polymorphic? && !r.polymorphic_child?
@@ -59,6 +59,9 @@ module Graphiti
59
59
  @query = query
60
60
  @opts = opts
61
61
 
62
+ @resolved_sideload_proxies = {}
63
+ @resolved_sideload_proxies_lock = Mutex.new
64
+
62
65
  @object = @resource.around_scoping(@object, @query.hash) { |scope|
63
66
  apply_scoping(scope, opts)
64
67
  }
@@ -67,7 +70,7 @@ module Graphiti
67
70
  def resolve(&blk)
68
71
  # The caller blocks on .value! either way, so concurrency only benefits parallel sideloads
69
72
  # See https://github.com/graphiti-api/graphiti/issues/505
70
- if self.class.resolve_synchronously? || !applicable_sideloads?
73
+ if self.class.resolve_synchronously? || !overlapping_sideloads?
71
74
  sync_resolve(&blk)
72
75
  else
73
76
  future_resolve(&blk).value!
@@ -140,9 +143,6 @@ module Graphiti
140
143
 
141
144
  private
142
145
 
143
- # Synchronous counterpart to #future_resolve, used when concurrency is off.
144
- # Resolves the resource and its sideloads inline without any promise
145
- # machinery. See #resolve.
146
146
  def sync_resolve(&blk)
147
147
  return [] if @query.zero_results?
148
148
 
@@ -151,14 +151,15 @@ module Graphiti
151
151
  resolved
152
152
  end
153
153
 
154
- # Synchronous counterpart to #future_resolve_sideloads, used when
155
- # concurrency is off. Resolves each sideload inline. See #resolve_sideloads.
156
154
  def sync_resolve_sideloads(results)
157
155
  return if results == []
158
156
 
159
- each_applicable_sideload do |sideload, sideload_query|
157
+ reset_captured_sideload_proxies
158
+ each_applicable_sideload do |name, sideload, sideload_query|
160
159
  Graphiti.config.before_sideload&.call(Graphiti.context)
161
- sideload.resolve(results, sideload_query, @resource)
160
+ sideload.sync_resolve(results, sideload_query, @resource) do |proxy|
161
+ capture_sideload_proxy(name, proxy)
162
+ end
162
163
  end
163
164
  end
164
165
 
@@ -176,7 +177,7 @@ module Graphiti
176
177
  assign_serializer(resolved)
177
178
  yield resolved if block_given?
178
179
  @opts[:after_resolve]&.call(resolved)
179
- resolved
180
+ @resolved_records = resolved
180
181
  end
181
182
 
182
183
  # Must run before sideloads assign, so every include path populates the
@@ -198,6 +199,8 @@ module Graphiti
198
199
 
199
200
  # A customized sideload can load a record another path would not, so it keeps its own instances.
200
201
  def deduplicable?
202
+ return false unless @query.repeated_resource_classes.include?(@resource.class)
203
+
201
204
  sideload = @opts[:sideload]
202
205
  return true unless sideload
203
206
 
@@ -207,9 +210,15 @@ module Graphiti
207
210
  sideload.primary_key == :id
208
211
  end
209
212
 
210
- # Nothing to post to the pool means the future would wrap work already done.
211
- def applicable_sideloads?
212
- each_applicable_sideload { return true }
213
+ # One sideload has nothing to run beside it, so the pool would hand the work
214
+ # to another thread and wait for it. A chain is one at every level. A
215
+ # polymorphic sideload counts as its children, which do run beside each other.
216
+ def overlapping_sideloads?
217
+ found = 0
218
+ each_applicable_sideload do |_, sideload, _|
219
+ found += sideload.respond_to?(:children) ? sideload.children.size : 1
220
+ return true if found > 1
221
+ end
213
222
  false
214
223
  end
215
224
 
@@ -218,18 +227,34 @@ module Graphiti
218
227
  sideload = @resource.class.sideload(name)
219
228
  next if sideload.nil? || sideload.shared_remote?
220
229
 
221
- yield sideload, sideload_query
230
+ yield name, sideload, sideload_query
231
+ end
232
+ end
233
+
234
+ # The write path resolves sideloads twice on one scope, which would double every proxy in the cache key.
235
+ def reset_captured_sideload_proxies
236
+ @resolved_sideload_proxies_lock.synchronize { @resolved_sideload_proxies.clear }
237
+ end
238
+
239
+ # A proxy built while resolving is already resolved all the way down.
240
+ def capture_sideload_proxy(name, proxy)
241
+ @resolved_sideload_proxies_lock.synchronize do
242
+ captured = (@resolved_sideload_proxies[name] ||= [])
243
+ captured << proxy unless proxy.nil? || proxy == []
222
244
  end
223
245
  end
224
246
 
225
247
  def future_resolve_sideloads(results)
226
248
  return Concurrent::Promises.fulfilled_future(nil, self.class.global_thread_pool_executor) if results == []
227
249
 
250
+ reset_captured_sideload_proxies
228
251
  sideload_promises = []
229
- each_applicable_sideload do |sideload, sideload_query|
252
+ each_applicable_sideload do |name, sideload, sideload_query|
230
253
  promise = future_with_context(results, sideload_query, @resource) do |parent_results, future_query, parent_resource|
231
254
  Graphiti.config.before_sideload&.call(Graphiti.context)
232
- sideload.future_resolve(parent_results, future_query, parent_resource)
255
+ sideload.future_resolve(parent_results, future_query, parent_resource) do |proxy|
256
+ capture_sideload_proxy(name, proxy)
257
+ end
233
258
  end
234
259
  sideload_promises << promise.flat
235
260
  end
@@ -257,15 +282,21 @@ module Graphiti
257
282
  end
258
283
  end
259
284
 
285
+ current_attributes = current_attributes_snapshot
286
+
260
287
  Concurrent::Promises.future_on(
261
- self.class.global_thread_pool_executor, Thread.current.object_id, thread_storage, fiber_storage, *args
262
- ) do |thread_id, thread_storage, fiber_storage, *args|
288
+ self.class.global_thread_pool_executor, Thread.current.object_id, thread_storage, fiber_storage, current_attributes, *args
289
+ ) do |thread_id, thread_storage, fiber_storage, current_attributes, *args|
263
290
  self.class.marking_pool_thread do
264
291
  wrap_in_rails_executor do
265
292
  with_thread_locals(thread_storage) do
266
293
  with_fiber_locals(fiber_storage) do
267
- Graphiti.broadcast(:global_thread_pool_task_run, self.class.global_thread_pool_stats) do
268
- yield(*args)
294
+ with_current_attributes(current_attributes) do
295
+ with_connection_pool_hint do
296
+ Graphiti.broadcast(:global_thread_pool_task_run, self.class.global_thread_pool_stats) do
297
+ yield(*args)
298
+ end
299
+ end
269
300
  end
270
301
  end
271
302
  end
@@ -274,6 +305,45 @@ module Graphiti
274
305
  end
275
306
  end
276
307
 
308
+ def current_attributes_snapshot
309
+ return unless defined?(ActiveSupport::CurrentAttributes)
310
+
311
+ snapshot = {}
312
+ klasses = ActiveSupport::CurrentAttributes.subclasses
313
+ while (klass = klasses.shift)
314
+ klasses.concat(klass.subclasses)
315
+ attributes = klass.attributes
316
+ snapshot[klass] = attributes.dup if attributes.any?
317
+ end
318
+ snapshot
319
+ end
320
+
321
+ # Restored inside the Rails executor, whose entry hands the pool thread a
322
+ # fresh Current. Restoring through set scopes the values to the block.
323
+ def with_current_attributes(snapshot, &block)
324
+ return yield if snapshot.nil? || snapshot.empty?
325
+
326
+ klass, attributes = snapshot.first
327
+ rest = snapshot.except(klass)
328
+ klass.set(attributes) { with_current_attributes(rest, &block) }
329
+ end
330
+
331
+ # A timeout inside a sideload task nearly always means database.yml's pool
332
+ # was not sized for the sideload threads, and the bare error does not say so.
333
+ def with_connection_pool_hint
334
+ yield
335
+ rescue => error
336
+ raise unless defined?(ActiveRecord::ConnectionTimeoutError) && error.is_a?(ActiveRecord::ConnectionTimeoutError)
337
+
338
+ hinted = error.exception(<<~MSG.strip)
339
+ #{error.message}
340
+
341
+ Raised while resolving a sideload concurrently. Each concurrent sideload holds its own database connection, so `pool` in database.yml must be at least web threads + concurrency_max_threads (#{Graphiti.config.concurrency_max_threads}) + 1. See graphiti.dev/concepts/resources#concurrency-pool-sizing.
342
+ MSG
343
+ hinted.set_backtrace(error.backtrace)
344
+ raise hinted
345
+ end
346
+
277
347
  def with_thread_locals(thread_locals)
278
348
  new_thread_locals = []
279
349
  thread_locals.each do |key, value|
@@ -316,15 +386,19 @@ module Graphiti
316
386
 
317
387
  def sideload_resource_proxies
318
388
  @sideload_resource_proxies ||= begin
319
- @object = @resource.before_resolve(@object, @query)
320
- results = @resource.resolve(@object)
389
+ # Reached after the response resolved, where resolving again re-applies before_resolve to a mutated scope.
390
+ results = @resolved_records
391
+ if results.nil?
392
+ @object = @resource.before_resolve(@object, @query)
393
+ results = @resource.resolve(@object)
394
+ end
321
395
 
322
396
  [].tap do |proxies|
323
- unless @query.sideloads.empty?
324
- @query.sideloads.each_pair do |name, q|
325
- sideload = @resource.class.sideload(name)
326
- next if sideload.nil? || sideload.shared_remote?
327
-
397
+ each_applicable_sideload do |name, sideload, q|
398
+ captured = @resolved_sideload_proxies[name]
399
+ if captured
400
+ proxies.concat(captured)
401
+ else
328
402
  proxies << sideload.build_resource_proxy(results, q, parent_resource)
329
403
  end
330
404
  end
@@ -62,7 +62,7 @@ module Graphiti
62
62
  value = parse_string_value(filter.values[0], value)
63
63
  end
64
64
 
65
- check_deny_empty_filters!(resource, filter, value)
65
+ check_blank_filters!(resource, filter, value)
66
66
  value = parse_string_null(filter.values[0], value)
67
67
  validate_singular(resource, filter, value)
68
68
  value = coerce_types(filter.values[0], param_name.to_sym, value)
@@ -209,15 +209,15 @@ module Graphiti
209
209
  end
210
210
 
211
211
  def parse_string_null(filter, value)
212
- return value unless filter[:allow_nil]
212
+ return value unless filter[:blanks] == :null
213
213
  return value.map { |item| (item == "null") ? nil : item } if value.is_a?(Array)
214
214
  return if value == "null"
215
215
 
216
216
  value
217
217
  end
218
218
 
219
- def check_deny_empty_filters!(resource, filter, value)
220
- return unless filter.values[0][:deny_empty]
219
+ def check_blank_filters!(resource, filter, value)
220
+ return unless filter.values[0][:blanks] == :rejected
221
221
 
222
222
  if value.nil? || value.empty? || value == "null"
223
223
  raise Errors::InvalidFilterValue.new(resource, filter, "(empty)")
@@ -4,9 +4,9 @@ module Graphiti
4
4
  PARAMS = [:number, :size, :offset, :before, :after]
5
5
 
6
6
  def apply
7
- if size > resource.max_page_size
7
+ if size > resource.page_max_size
8
8
  raise Graphiti::Errors::UnsupportedPageSize
9
- .new(size, resource.max_page_size)
9
+ .new(size, resource.page_max_size)
10
10
  elsif requested? && @opts[:sideload_parent_length].to_i > 1
11
11
  raise Graphiti::Errors::UnsupportedPagination
12
12
  else
@@ -100,7 +100,7 @@ module Graphiti
100
100
  end
101
101
 
102
102
  def size
103
- (page_param[:size] || resource.default_page_size || DEFAULT_PAGE_SIZE).to_i
103
+ (page_param[:size] || resource.page_default_size || DEFAULT_PAGE_SIZE).to_i
104
104
  end
105
105
  end
106
106
  end