graphiti 2.0.0.beta.8 → 2.0.0.beta.10

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/docs.yml +9 -6
  4. data/.github/workflows/release.yml +1 -1
  5. data/CHANGELOG.md +43 -0
  6. data/Rakefile +28 -0
  7. data/UPGRADING.md +1 -1
  8. data/lib/generators/graphiti/generator_mixin.rb +24 -0
  9. data/lib/generators/graphiti/templates/application_resource.rb.erb +9 -16
  10. data/lib/graphiti/configuration.rb +66 -28
  11. data/lib/graphiti/debugger.rb +18 -6
  12. data/lib/graphiti/delegates/pagination.rb +2 -2
  13. data/lib/graphiti/error_serializers/deprecated_constants.rb +23 -0
  14. data/lib/graphiti/error_serializers/invalid_request.rb +6 -0
  15. data/lib/graphiti/errors.rb +51 -3
  16. data/lib/graphiti/query.rb +36 -14
  17. data/lib/graphiti/rails/controller.rb +26 -6
  18. data/lib/graphiti/rails/rake_helpers.rb +17 -0
  19. data/lib/graphiti/rails.rb +14 -0
  20. data/lib/graphiti/railtie.rb +8 -0
  21. data/lib/graphiti/request_validators/update_validator.rb +1 -1
  22. data/lib/graphiti/request_validators/validator.rb +9 -0
  23. data/lib/graphiti/resource/configuration.rb +138 -40
  24. data/lib/graphiti/resource/dsl.rb +21 -2
  25. data/lib/graphiti/resource/interface.rb +1 -1
  26. data/lib/graphiti/resource/links.rb +36 -5
  27. data/lib/graphiti/resource/remote.rb +2 -1
  28. data/lib/graphiti/resource/sideloading.rb +2 -2
  29. data/lib/graphiti/schema.rb +2 -2
  30. data/lib/graphiti/scope.rb +115 -23
  31. data/lib/graphiti/scoping/filter.rb +4 -4
  32. data/lib/graphiti/scoping/paginate.rb +3 -3
  33. data/lib/graphiti/serializer.rb +12 -6
  34. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +11 -4
  35. data/lib/graphiti/sideload.rb +22 -14
  36. data/lib/graphiti/spec_helpers/matchers.rb +2 -2
  37. data/lib/graphiti/spec_helpers/rspec.rb +6 -3
  38. data/lib/graphiti/util/link.rb +1 -1
  39. data/lib/graphiti/util/serializer_attributes.rb +5 -3
  40. data/lib/graphiti/util/serializer_relationships.rb +26 -16
  41. data/lib/graphiti/version.rb +1 -1
  42. data/lib/graphiti.rb +6 -1
  43. data/lib/tasks/graphiti.rake +4 -0
  44. data/package-lock.json +114 -0
  45. data/package.json +16 -3
  46. metadata +3 -2
@@ -31,6 +31,28 @@ module Graphiti
31
31
  end
32
32
  end
33
33
 
34
+ POOL_THREAD = :__graphiti_pool_thread
35
+ private_constant :POOL_THREAD
36
+
37
+ # A pool thread that waits on the pool deadlocks, since the task it waits for cannot start until the waiting thread frees its slot.
38
+ def self.resolve_synchronously?
39
+ !Graphiti.config.concurrency || on_pool_thread?
40
+ end
41
+
42
+ # TODO: move to Fiber[] once the floor is Ruby 3.2
43
+ def self.on_pool_thread?
44
+ Thread.current[POOL_THREAD] == true
45
+ end
46
+
47
+ # Restores rather than clears because :caller_runs may have run the task on a request thread.
48
+ def self.marking_pool_thread
49
+ previous = Thread.current[POOL_THREAD]
50
+ Thread.current[POOL_THREAD] = true
51
+ yield
52
+ ensure
53
+ Thread.current[POOL_THREAD] = previous
54
+ end
55
+
34
56
  def initialize(object, resource, query, opts = {})
35
57
  @object = object
36
58
  @resource = resource
@@ -43,24 +65,24 @@ module Graphiti
43
65
  end
44
66
 
45
67
  def resolve(&blk)
46
- # When concurrency is disabled, take a synchronous path that mirrors the
47
- # pre-1.8 semantics. This avoids allocating Concurrent::Promises futures,
48
- # Thread/Fiber storage snapshots, and Rails executor wrappers on every
49
- # request purely to drive a thread pool that is intentionally synchronous.
68
+ # The caller blocks on .value! either way, so concurrency only benefits parallel sideloads
50
69
  # See https://github.com/graphiti-api/graphiti/issues/505
51
- if Graphiti.config.concurrency
52
- future_resolve(&blk).value!
53
- else
70
+ if self.class.resolve_synchronously? || !applicable_sideloads?
54
71
  sync_resolve(&blk)
72
+ else
73
+ future_resolve(&blk).value!
55
74
  end
56
75
  end
57
76
 
58
77
  def resolve_sideloads(results)
59
- if Graphiti.config.concurrency
60
- future_resolve_sideloads(results).value!
61
- else
78
+ if self.class.resolve_synchronously?
62
79
  sync_resolve_sideloads(results)
80
+ else
81
+ future_resolve_sideloads(results).value!
63
82
  end
83
+
84
+ # Never return the sideloads hash, a caller mutating it would mess up the cache key
85
+ nil
64
86
  end
65
87
 
66
88
  def future_resolve(&blk)
@@ -138,10 +160,6 @@ module Graphiti
138
160
  Graphiti.config.before_sideload&.call(Graphiti.context)
139
161
  sideload.resolve(results, sideload_query, @resource)
140
162
  end
141
-
142
- # Match pre-1.8 semantics: the non-concurrent resolve_sideloads returned
143
- # nil (not the sideloads Hash). Callers don't rely on the return value.
144
- nil
145
163
  end
146
164
 
147
165
  # Resolve this scope's own data: run hooks, resolve the resource, and
@@ -165,14 +183,36 @@ module Graphiti
165
183
  # canonical instance. The resource class in the key keeps two resources
166
184
  # serving the same model from sharing an instance and a serializer.
167
185
  def deduplicate_entities!(resolved)
186
+ return unless deduplicable?
187
+
188
+ # Nested maps are built once, a composite key meant a new array for every record
189
+ by_model = @query.entity_map.compute_if_absent(@resource.class) { Concurrent::Map.new }
190
+
168
191
  resolved.map! do |record|
169
192
  next record unless record.respond_to?(:id) && !record.id.nil?
170
193
 
171
- key = [@resource.class, record.class, record.id]
172
- @query.entity_map.compute_if_absent(key) { record }
194
+ by_id = by_model.compute_if_absent(record.class) { Concurrent::Map.new }
195
+ by_id.compute_if_absent(record.id) { record }
173
196
  end
174
197
  end
175
198
 
199
+ # A customized sideload can load a record another path would not, so it keeps its own instances.
200
+ def deduplicable?
201
+ sideload = @opts[:sideload]
202
+ return true unless sideload
203
+
204
+ sideload.scope_proc.nil? &&
205
+ sideload.params_proc.nil? &&
206
+ !sideload.customized_base_scope? &&
207
+ sideload.primary_key == :id
208
+ end
209
+
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
+ false
214
+ end
215
+
176
216
  def each_applicable_sideload
177
217
  @query.sideloads.each_pair do |name, sideload_query|
178
218
  sideload = @resource.class.sideload(name)
@@ -194,6 +234,8 @@ module Graphiti
194
234
  sideload_promises << promise.flat
195
235
  end
196
236
 
237
+ return sideload_promises.first if sideload_promises.one?
238
+
197
239
  Concurrent::Promises.zip_futures_on(self.class.global_thread_pool_executor, *sideload_promises)
198
240
  .rescue_on(self.class.global_thread_pool_executor) do |*reasons|
199
241
  first_error = reasons.find { |r| r.is_a?(Exception) }
@@ -202,7 +244,10 @@ module Graphiti
202
244
  end
203
245
 
204
246
  def future_with_context(*args)
247
+ # TODO: we only need Fiber.storage after Ruby 3.2 is the floor
205
248
  thread_storage = Thread.current.keys.each_with_object({}) do |key, memo|
249
+ next if key == POOL_THREAD
250
+
206
251
  memo[key] = Thread.current[key]
207
252
  end
208
253
  fiber_storage =
@@ -212,14 +257,22 @@ module Graphiti
212
257
  end
213
258
  end
214
259
 
260
+ current_attributes = current_attributes_snapshot
261
+
215
262
  Concurrent::Promises.future_on(
216
- self.class.global_thread_pool_executor, Thread.current.object_id, thread_storage, fiber_storage, *args
217
- ) do |thread_id, thread_storage, fiber_storage, *args|
218
- wrap_in_rails_executor do
219
- with_thread_locals(thread_storage) do
220
- with_fiber_locals(fiber_storage) do
221
- Graphiti.broadcast(:global_thread_pool_task_run, self.class.global_thread_pool_stats) do
222
- yield(*args)
263
+ self.class.global_thread_pool_executor, Thread.current.object_id, thread_storage, fiber_storage, current_attributes, *args
264
+ ) do |thread_id, thread_storage, fiber_storage, current_attributes, *args|
265
+ self.class.marking_pool_thread do
266
+ wrap_in_rails_executor do
267
+ with_thread_locals(thread_storage) do
268
+ with_fiber_locals(fiber_storage) do
269
+ with_current_attributes(current_attributes) do
270
+ with_connection_pool_hint do
271
+ Graphiti.broadcast(:global_thread_pool_task_run, self.class.global_thread_pool_stats) do
272
+ yield(*args)
273
+ end
274
+ end
275
+ end
223
276
  end
224
277
  end
225
278
  end
@@ -227,6 +280,45 @@ module Graphiti
227
280
  end
228
281
  end
229
282
 
283
+ def current_attributes_snapshot
284
+ return unless defined?(ActiveSupport::CurrentAttributes)
285
+
286
+ snapshot = {}
287
+ klasses = ActiveSupport::CurrentAttributes.subclasses
288
+ while (klass = klasses.shift)
289
+ klasses.concat(klass.subclasses)
290
+ attributes = klass.attributes
291
+ snapshot[klass] = attributes.dup if attributes.any?
292
+ end
293
+ snapshot
294
+ end
295
+
296
+ # Restored inside the Rails executor, whose entry hands the pool thread a
297
+ # fresh Current. Restoring through set scopes the values to the block.
298
+ def with_current_attributes(snapshot, &block)
299
+ return yield if snapshot.nil? || snapshot.empty?
300
+
301
+ klass, attributes = snapshot.first
302
+ rest = snapshot.except(klass)
303
+ klass.set(attributes) { with_current_attributes(rest, &block) }
304
+ end
305
+
306
+ # A timeout inside a sideload task nearly always means database.yml's pool
307
+ # was not sized for the sideload threads, and the bare error does not say so.
308
+ def with_connection_pool_hint
309
+ yield
310
+ rescue => error
311
+ raise unless defined?(ActiveRecord::ConnectionTimeoutError) && error.is_a?(ActiveRecord::ConnectionTimeoutError)
312
+
313
+ hinted = error.exception(<<~MSG.strip)
314
+ #{error.message}
315
+
316
+ 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.
317
+ MSG
318
+ hinted.set_backtrace(error.backtrace)
319
+ raise hinted
320
+ end
321
+
230
322
  def with_thread_locals(thread_locals)
231
323
  new_thread_locals = []
232
324
  thread_locals.each do |key, value|
@@ -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] == :as_nil
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] == :reject
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
@@ -1,5 +1,7 @@
1
1
  module Graphiti
2
2
  class Serializer < JSONAPI::Serializable::Resource
3
+ UNREQUESTED_LINKS = [false, nil, "false"].freeze
4
+
3
5
  include Graphiti::Extensions::BooleanAttribute
4
6
  include Graphiti::Extensions::ExtraAttribute
5
7
  include Graphiti::SerializableHash
@@ -59,7 +61,7 @@ module Graphiti
59
61
  starting_offset = 0
60
62
  page_param = @proxy.query.pagination
61
63
  if (page_number = page_param[:number])
62
- page_size = page_param[:size] || @resource.default_page_size
64
+ page_size = page_param[:size] || @resource.page_default_size
63
65
  starting_offset = (page_number - 1) * page_size
64
66
  end
65
67
 
@@ -100,17 +102,21 @@ module Graphiti
100
102
  hash[:links] = @resource.links(@object) if @resource.links?
101
103
  end
102
104
 
105
+ # A relationship whose only content would be an unrequested on-demand link
106
+ # serializes as an empty stub, which JSON:API forbids.
103
107
  def strip_relationships!(hash)
104
- hash[:relationships]&.select! do |name, payload|
105
- payload.key?(:data)
108
+ hash[:relationships]&.reject! do |name, payload|
109
+ next false if payload.key?(:data) || payload.key?(:links)
110
+
111
+ self.class.relationship_sideloads[name]&.link_mode == :on_demand
106
112
  end
107
113
  end
108
114
 
109
115
  def strip_relationships?
110
- return false unless Graphiti.config.links_on_demand
111
- params = Graphiti.context[:object]&.params || {}
116
+ context = Graphiti.context[:object]
117
+ params = context.params if context.respond_to?(:params)
112
118
 
113
- [false, nil, "false"].include?(params[:links])
119
+ UNREQUESTED_LINKS.include?(params && params[:links])
114
120
  end
115
121
  end
116
122
  end
@@ -108,10 +108,10 @@ class Graphiti::Sideload::PolymorphicBelongsTo < Graphiti::Sideload::BelongsTo
108
108
  end
109
109
 
110
110
  def resolve(parents, query, graph_parent)
111
- if Graphiti.config.concurrency
112
- future_resolve(parents, query, graph_parent).value!
113
- else
111
+ if ::Graphiti::Scope.resolve_synchronously?
114
112
  sync_resolve(parents, query, graph_parent)
113
+ else
114
+ future_resolve(parents, query, graph_parent).value!
115
115
  end
116
116
  end
117
117
 
@@ -120,7 +120,14 @@ class Graphiti::Sideload::PolymorphicBelongsTo < Graphiti::Sideload::BelongsTo
120
120
  each_resolvable_group(parents, query) do |child, group, child_query|
121
121
  promises << child.future_resolve(group, child_query, graph_parent)
122
122
  end
123
- Concurrent::Promises.zip(*promises)
123
+ return promises.first if promises.one?
124
+
125
+ executor = ::Graphiti::Scope.global_thread_pool_executor
126
+ Concurrent::Promises.zip_futures_on(executor, *promises)
127
+ .rescue_on(executor) do |*reasons|
128
+ first_error = reasons.find { |reason| reason.is_a?(Exception) }
129
+ raise first_error
130
+ end
124
131
  end
125
132
 
126
133
  private
@@ -31,6 +31,9 @@ module Graphiti
31
31
  @writable = opts[:writable]
32
32
  @as = opts[:as]
33
33
  @link = opts[:link]
34
+ unless @link.nil? || Resource::LINK_MODES.include?(@link)
35
+ raise Errors::InvalidLinkRendering.new(@parent_resource_class, :"#{name} link", @link)
36
+ end
34
37
  @single = opts[:single]
35
38
  @remote = opts[:remote]
36
39
  apply_belongs_to_many_filter if type == :many_to_many
@@ -84,7 +87,8 @@ module Graphiti
84
87
  self.adapter = Graphiti::Adapters::GraphitiAPI
85
88
  self.model = OpenStruct
86
89
  self.remote = remote_url
87
- self.validate_endpoints = false
90
+ self.validate_requests = false
91
+ self.validate_links = false
88
92
  }
89
93
  name = "#{parent_resource_class.name}.#{@name}.remote"
90
94
  klass.class_eval("def self.name;'#{name}';end", __FILE__, __LINE__)
@@ -166,14 +170,16 @@ module Graphiti
166
170
  false
167
171
  end
168
172
 
169
- def link?
170
- return true if link_proc
173
+ # A custom link block means the author wants the link, so a false default does not silence it.
174
+ def link_mode
175
+ return @link unless @link.nil?
171
176
 
172
- if @link.nil?
173
- !!@parent_resource_class.autolink
174
- else
175
- !!@link
176
- end
177
+ default = @parent_resource_class.relationship_links
178
+ (link_proc && default == false) ? true : default
179
+ end
180
+
181
+ def link?
182
+ link_mode != false
177
183
  end
178
184
 
179
185
  def link_filter(parents)
@@ -280,9 +286,11 @@ module Graphiti
280
286
  end
281
287
 
282
288
  def load(parents, query, graph_parent)
283
- return build_resource_proxy(parents, query, graph_parent).to_a unless Graphiti.config.concurrency
284
-
285
- future_load(parents, query, graph_parent).value!
289
+ if Scope.resolve_synchronously?
290
+ build_resource_proxy(parents, query, graph_parent).to_a
291
+ else
292
+ future_load(parents, query, graph_parent).value!
293
+ end
286
294
  end
287
295
 
288
296
  # Override in subclass
@@ -333,10 +341,10 @@ module Graphiti
333
341
  end
334
342
 
335
343
  def resolve(parents, query, graph_parent)
336
- if Graphiti.config.concurrency
337
- future_resolve(parents, query, graph_parent).value!
338
- else
344
+ if Scope.resolve_synchronously?
339
345
  sync_resolve(parents, query, graph_parent)
346
+ else
347
+ future_resolve(parents, query, graph_parent).value!
340
348
  end
341
349
  end
342
350
 
@@ -140,7 +140,7 @@ module Graphiti
140
140
  end
141
141
 
142
142
  class FilterAttributeMatcher < ResourceDSLMatcher
143
- GRAPHITI_OPTS = %i[allow deny single required allow_nil deny_empty].freeze
143
+ GRAPHITI_OPTS = %i[allow deny single required blanks].freeze
144
144
  GRAPHITI_CONFIG_KEY = :filters
145
145
  EXPECTED_ACTION = "filter"
146
146
  end
@@ -186,7 +186,7 @@ module Graphiti
186
186
  # @param [Symbol] type
187
187
  #
188
188
  # @example expect(subject).to filter_attribute(:name, :string)
189
- # @example expect(subject).to filter_attribute(:name, :string).with_options(allow_nil: false)
189
+ # @example expect(subject).to filter_attribute(:name, :string).with_options(blanks: :as_nil)
190
190
  # @example expect(subject).not_to filter_attribute(:name, :string)
191
191
  def filter_attribute(attribute, type)
192
192
  FilterAttributeMatcher.new(attribute, type)
@@ -39,14 +39,17 @@ resource_testing = proc do
39
39
  let(:params) { {} }
40
40
 
41
41
  around do |e|
42
- original = Graphiti::Resource.validate_endpoints
43
- Graphiti::Resource.validate_endpoints = false
42
+ original_requests = Graphiti::Resource.validate_requests
43
+ original_links = Graphiti::Resource.validate_links
44
+ Graphiti::Resource.validate_requests = false
45
+ Graphiti::Resource.validate_links = false
44
46
 
45
47
  Graphiti.with_context graphiti_context do
46
48
  e.run
47
49
  end
48
50
  ensure
49
- Graphiti::Resource.validate_endpoints = original
51
+ Graphiti::Resource.validate_requests = original_requests
52
+ Graphiti::Resource.validate_links = original_links
50
53
  end
51
54
 
52
55
  def graphiti_context
@@ -47,7 +47,7 @@ module Graphiti
47
47
  end
48
48
 
49
49
  def on_demand_links(url)
50
- return url unless Graphiti.config.links_on_demand
50
+ return url unless @sideload.resource.relationship_links == :on_demand
51
51
  return unless url
52
52
 
53
53
  url << if url.include?("?")
@@ -30,7 +30,7 @@ module Graphiti
30
30
  @serializer.send(:"#{applied_method}=", [@name] | existing)
31
31
 
32
32
  @serializer.meta do
33
- if !!@resource.try(:cursor_paginatable?) && !Graphiti.context[:graphql]
33
+ if !!@resource.try(:page_cursors?) && !Graphiti.context[:graphql]
34
34
  {cursor: cursor}
35
35
  end
36
36
  end
@@ -121,10 +121,11 @@ module Graphiti
121
121
 
122
122
  def default_proc
123
123
  name_ref = @name
124
+ resource_ref = @resource
124
125
  typecast_ref = typecast(Graphiti::Types[@attr[:type]][:read])
125
126
  ->(_) {
126
127
  val = @object.send(name_ref)
127
- if Graphiti.config.typecast_reads
128
+ if resource_ref.typecast_reads
128
129
  typecast_ref.call(val)
129
130
  else
130
131
  val
@@ -133,10 +134,11 @@ module Graphiti
133
134
  end
134
135
 
135
136
  def wrap_proc(inner)
137
+ resource_ref = @resource
136
138
  typecast_ref = typecast(Graphiti::Types[@attr[:type]][:read])
137
139
  ->(serializer_instance = nil) {
138
140
  val = serializer_instance.instance_eval(&inner)
139
- if Graphiti.config.typecast_reads
141
+ if resource_ref.typecast_reads
140
142
  typecast_ref.call(val)
141
143
  else
142
144
  val
@@ -8,6 +8,8 @@ module Graphiti
8
8
  end
9
9
 
10
10
  def apply
11
+ return unless @serializer
12
+
11
13
  @sideloads.each_pair do |name, sideload|
12
14
  if apply?(sideload)
13
15
  SerializerRelationship
@@ -55,8 +57,8 @@ module Graphiti
55
57
  private
56
58
 
57
59
  def block
58
- link_ref = link?
59
60
  sideload_ref = @sideload
61
+ resource_class_ref = @resource_class
60
62
  data_proc_ref = data_proc
61
63
  self_ref = self
62
64
  validate_link! if eagerly_validate_links?
@@ -71,7 +73,15 @@ module Graphiti
71
73
  if sideload_ref.resource_ids_from_foreign_key? &&
72
74
  !self_ref.send(:included_anywhere?, @proxy.query.include_hash, sideload_ref.name)
73
75
  linkage always: sideload_ref.render_resource_ids? do
74
- foreign_key = @object.public_send(sideload_ref.foreign_key)
76
+ foreign_key = begin
77
+ @object.public_send(sideload_ref.foreign_key)
78
+ rescue NoMethodError => error
79
+ raise unless defined?(ActiveModel::MissingAttributeError) &&
80
+ error.is_a?(ActiveModel::MissingAttributeError)
81
+
82
+ raise Errors::UnselectedForeignKey
83
+ .new(resource_class_ref, sideload_ref, @object)
84
+ end
75
85
 
76
86
  unless foreign_key.nil?
77
87
  {
@@ -84,13 +94,11 @@ module Graphiti
84
94
  linkage always: sideload_ref.render_resource_ids?
85
95
  end
86
96
 
87
- if link_ref
88
- if @proxy.query.links?
89
- self_ref.send(:validate_link!) unless self_ref.send(:eagerly_validate_links?)
97
+ if @proxy.query.render_link?(sideload_ref.link_mode) && self_ref.send(:linkable?)
98
+ self_ref.send(:validate_link!) unless self_ref.send(:eagerly_validate_links?)
90
99
 
91
- link(:related) do
92
- ::Graphiti::Util::Link.new(sideload_ref, @object).generate
93
- end
100
+ link(:related) do
101
+ ::Graphiti::Util::Link.new(sideload_ref, @object).generate
94
102
  end
95
103
  end
96
104
  end
@@ -153,8 +161,8 @@ module Graphiti
153
161
  end
154
162
 
155
163
  def validate_link!
156
- return unless link?
157
- return unless @resource_class.validate_endpoints?
164
+ return unless @sideload.link? && linkable?
165
+ return unless @resource_class.validate_links?
158
166
  return if @sideload.link_proc
159
167
 
160
168
  unless Graphiti.config.context_for_endpoint
@@ -183,14 +191,16 @@ module Graphiti
183
191
  self.class.validated_link_cache << cache_key
184
192
  end
185
193
 
186
- def link?
187
- return true if @sideload.link_proc
194
+ # Checked lazily so a sideload with no endpoint only raises when a link is actually wanted.
195
+ def linkable?
196
+ return @linkable if defined?(@linkable)
188
197
 
189
- if @sideload.respond_to?(:children)
190
- @sideload.link? &&
191
- @sideload.children.values.all? { |c| !c.resource.endpoint.nil? }
198
+ @linkable = if @sideload.link_proc
199
+ true
200
+ elsif @sideload.respond_to?(:children)
201
+ @sideload.children.values.all? { |c| !c.resource.endpoint.nil? }
192
202
  else
193
- !!(@sideload.link? && @sideload.resource.endpoint)
203
+ !@sideload.resource.endpoint.nil?
194
204
  end
195
205
  end
196
206
  end
@@ -1,3 +1,3 @@
1
1
  module Graphiti
2
- VERSION = "2.0.0.beta.8"
2
+ VERSION = "2.0.0.beta.10"
3
3
  end
data/lib/graphiti.rb CHANGED
@@ -33,7 +33,7 @@ require "jsonapi/serializable"
33
33
  # either way, whichever copy wins.
34
34
  {
35
35
  "graphiti_spec_helpers" => 'The "graphiti_spec_helpers/rspec" require and the GraphitiSpecHelpers namespace are unchanged.',
36
- "graphiti-rails" => 'Graphiti::Rails and its config.graphiti options are unchanged. Drop the "graphiti-rails" require if you have one.',
36
+ "graphiti-rails" => 'Graphiti::Rails and its config.graphiti options are unchanged. Drop the "graphiti-rails" require if you have one, and add `include Graphiti::Rails::Controller` to controllers serving Graphiti resources. graphiti-rails installed that on every controller automatically. See graphiti.dev/upgrading.',
37
37
  "graphiti_errors" => "Exception handling now goes through rescue_registry. Remove `include GraphitiErrors` from your controllers — Graphiti registers its own handlers, and you can add yours with `register_exception`."
38
38
  }.each do |gem_name, guidance|
39
39
  next unless Gem.loaded_specs.key?(gem_name)
@@ -149,6 +149,11 @@ module Graphiti
149
149
  resources.each do |r|
150
150
  r.apply_sideloads_to_serializer
151
151
  end
152
+ @setup = true
153
+ end
154
+
155
+ def self.setup?
156
+ !!@setup
152
157
  end
153
158
 
154
159
  def self.cache=(val)
@@ -22,6 +22,10 @@ namespace :graphiti do
22
22
  rows = Graphiti::Audit.run
23
23
  puts Graphiti::Audit::Report.new(rows)
24
24
 
25
+ if (advisory = helpers.connection_pool_advisory)
26
+ puts advisory
27
+ end
28
+
25
29
  exit 1 if rows.any?(&:error?)
26
30
  end
27
31