graphiti 2.0.0.beta.10 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1e331ab2dea47987ea47260eb0890c9a953d3435bba2e7ef37cbfbbc83fe5ab
4
- data.tar.gz: 13a7483ead9f4ad5ed69390ff6a4a122a819813c2147c0a56dceebdfebc57e17
3
+ metadata.gz: a639e421891e5d5e650d2d8131a75905cb9ffd0fb40cdfd15239ca5caf29d12f
4
+ data.tar.gz: 95172bbff224d598c959d5acc93c30861e01c50540fdea871ea5029473bf5259
5
5
  SHA512:
6
- metadata.gz: cca252389b52b5a2983407b4eff7cf7fe8b49759b478228f9d78303522bc40f2948a1784d6c863373f10521dc80b61d35f201009e830d8a968cc989b37d2ad81
7
- data.tar.gz: 3fdfda722d09ddea6c366ca4102258bdea6d36c923be729ae141063e7e62b43ae0730f30a6422ae4448f858a5c1d4269f80180279d7939ce050c88008613cedb
6
+ metadata.gz: 24c275cfcaecdba9ced126b7970b37962998673a3a8609c885a69befc53adba646ec2c7d9517198509e358ebba5300f3b336c86f02df98af9d18a6f4a4c306c3
7
+ data.tar.gz: e7d2fd4e0d0ff74b68ea4d3673399d1b72897ad7c8647fa38835ff2b58c850d58ba95a1145a52f55893e88e36eb0aa90bece72a2e6e593173661d16100666edc
@@ -13,6 +13,8 @@ jobs:
13
13
  uses: actions/checkout@v5
14
14
  with:
15
15
  persist-credentials: false
16
+ # semantic-release reads the tag history to work out the next version
17
+ fetch-depth: 0
16
18
  - name: Set up Ruby
17
19
  uses: ruby/setup-ruby@v1
18
20
  with:
data/.tool-versions CHANGED
@@ -1 +1 @@
1
- ruby 3.3.4
1
+ ruby 4.0.6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  graphiti changelog
2
2
 
3
+ # [2.0.0-beta.11](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.10...v2.0.0-beta.11) (2026-08-27)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * always render included when the client asked to include ([8691b3f](https://github.com/graphiti-api/graphiti/commit/8691b3fd08f3cae0481ba72839911fdc71ef5634))
9
+ * autolink = true no longer clobbers an inherited :on_demand ([2d26f8c](https://github.com/graphiti-api/graphiti/commit/2d26f8cb567e82a16918345de9114b8ceef64523))
10
+ * read the links param from the query, not the context object ([3b934cb](https://github.com/graphiti-api/graphiti/commit/3b934cb25be049e1e80ba400123da944cfd23d04))
11
+ * stop assigning the same child twice when a resource repeats in the include path ([588503b](https://github.com/graphiti-api/graphiti/commit/588503b109508495d8170373d80019c6971f7d9c))
12
+
13
+
14
+ ### Features
15
+
16
+ * omit relationships that render no ids and no link ([916aa8c](https://github.com/graphiti-api/graphiti/commit/916aa8c9c596c5d9b6c802c5ab13b766a13d1e70))
17
+
18
+
19
+ ### Performance Improvements
20
+
21
+ * memoize whether a belongs_to renders ids from its foreign key ([dcb60b2](https://github.com/graphiti-api/graphiti/commit/dcb60b2096c414756ccff534773d2e45594f0144))
22
+ * only deduplicate resources that two include paths can both reach ([744f014](https://github.com/graphiti-api/graphiti/commit/744f014249382bc25d5ea43f692778ad7adc25c3))
23
+ * resolve a lone sideload inline rather than through the pool ([3415592](https://github.com/graphiti-api/graphiti/commit/3415592d75900389a8f11f53b65ab399e297e4a9))
24
+ * reuse the resolved data and proxies when building cache keys ([8a17350](https://github.com/graphiti-api/graphiti/commit/8a17350542ba0062fdcddd115afe8041b62198c7))
25
+ * skip the on-demand link strip when no relationship renders links on demand ([cf86a8c](https://github.com/graphiti-api/graphiti/commit/cf86a8c249077d9f9d5a13de49989d102bbaed04))
26
+
3
27
  # [2.0.0-beta.10](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.9...v2.0.0-beta.10) (2026-08-24)
4
28
 
5
29
 
data/Rakefile CHANGED
@@ -11,31 +11,36 @@ RSpec::Core::RakeTask.new(:spec) do |t|
11
11
  end
12
12
  end
13
13
 
14
+ # Allocations mean the same anywhere and timings do not, so the history has a
15
+ # reference CPU, named on every row, and recording timings refuses to mix them.
14
16
  namespace :performance do
15
17
  # Shelling out keeps a failure to the script's own message, with no rake backtrace on top.
16
18
  def measure(*arguments)
17
19
  exit(1) unless system("bundle", "exec", "ruby", "spec/performance/measure_releases.rb", *arguments)
18
20
  end
19
21
 
20
- desc "Compare the working tree to the last release, and plot it"
21
- task :current do
22
- measure
22
+ desc "Read a change: measure the working tree, print the comparison and plot it"
23
+ task :read do
24
+ measure("--read")
23
25
  end
24
26
 
25
- desc "Plot the release history plus the working tree to tmp/performance.html and open it"
26
- task :page do
27
- exit(1) unless system("bundle", "exec", "ruby", "spec/performance/chart_page.rb")
27
+ desc "Open the recorded history as a chart. Any machine, measures nothing"
28
+ task :chart do
29
+ exit(1) unless system("bundle", "exec", "ruby", "spec/performance/chart_page.rb", "--no-current")
28
30
  end
29
31
 
30
- desc "Measure one release and record it (TAG=v2.0.0-beta.9)"
32
+ desc "Fill in the history. Missing releases by default, TAG=v2.0.0-beta.9 for one, ALL=1 for every. Reference CPU"
31
33
  task :record do
32
- tag = ENV["TAG"] or abort "pass the release to record, e.g. rake performance:record TAG=v2.0.0-beta.9"
33
- measure(tag)
34
+ measure(*(if ENV["TAG"]
35
+ [ENV["TAG"]]
36
+ else
37
+ ENV["ALL"] ? ["--all"] : ["--missing"]
38
+ end))
34
39
  end
35
40
 
36
- desc "Re-record every release for this ruby, replacing its rows"
37
- task :record_all do
38
- measure("--all")
41
+ desc "Measure the working tree for the release to name, and commit it. Reference CPU"
42
+ task :pending do
43
+ measure("--pending")
39
44
  end
40
45
  end
41
46
 
@@ -371,10 +371,11 @@ module Graphiti
371
371
  activerecord_adapter.associate \
372
372
  parent, child, association_name, association_type
373
373
  elsif [:has_many, :many_to_many].include?(association_type)
374
- if parent.send(:"#{association_name}").nil?
374
+ associated = parent.send(:"#{association_name}")
375
+ if associated.nil?
375
376
  parent.send(:"#{association_name}=", [child])
376
- else
377
- parent.send(:"#{association_name}") << child
377
+ elsif associated.none? { |existing| existing.equal?(child) }
378
+ associated << child
378
379
  end
379
380
  else
380
381
  parent.send(:"#{association_name}=", child)
@@ -719,11 +719,11 @@ module Graphiti
719
719
 
720
720
  def message
721
721
  <<~MSG
722
- #{@resource_class.name}: #{@attribute} must be one of :as_literal, :as_nil, or :reject. Got #{@value.inspect}.
722
+ #{@resource_class.name}: #{@attribute} must be one of :literal, :null, or :rejected. Got #{@value.inspect}.
723
723
 
724
- :as_literal - "null" and "" reach the filter as strings (default)
725
- :as_nil - "null" becomes nil, so the filter can query for NULL
726
- :reject - a blank value raises InvalidFilterValue
724
+ :literal - "null" and "" reach the filter as strings (default)
725
+ :null - "null" becomes nil, so the filter can query for NULL
726
+ :rejected - a blank value raises InvalidFilterValue
727
727
  MSG
728
728
  end
729
729
  end
@@ -39,10 +39,37 @@ module Graphiti
39
39
  @entity_map
40
40
  end
41
41
 
42
+ # Deduplication exists for a row two include paths both resolve, so a
43
+ # resource class sitting at one node has nothing to collide with.
44
+ def repeated_resource_classes
45
+ return root.repeated_resource_classes unless root == self
46
+
47
+ @repeated_resource_classes ||= begin
48
+ counts = Hash.new(0)
49
+ walk = [self]
50
+ while (query = walk.pop)
51
+ counts[query.resource.class] += 1
52
+ query.sideloads.each_pair do |name, sideload_query|
53
+ walk << sideload_query
54
+ sideload = query.resource.class.sideload(name)
55
+ next unless sideload.respond_to?(:children)
56
+
57
+ # A polymorphic child resolves under a class no node names.
58
+ sideload.children.each_value { |child| counts[child.resource.class] += 2 }
59
+ end
60
+ end
61
+ counts.select { |_, count| count > 1 }.keys.to_set
62
+ end
63
+ end
64
+
42
65
  def top_level?
43
66
  !association?
44
67
  end
45
68
 
69
+ def includes_requested?
70
+ @include_param.present?
71
+ end
72
+
46
73
  def links_requested?
47
74
  return @links_requested if defined?(@links_requested)
48
75
 
@@ -13,8 +13,11 @@ module Graphiti
13
13
  @records ||= @proxy.data
14
14
  end
15
15
 
16
+ # JSON:API requires the key even when the include turns up nothing.
16
17
  def to_jsonapi
17
- render(self.class.jsonapi_renderer).to_json
18
+ rendered = render(self.class.jsonapi_renderer)
19
+ rendered[:included] ||= [] if proxy.query.includes_requested?
20
+ rendered.to_json
18
21
  end
19
22
 
20
23
  def as_graphql
@@ -6,7 +6,7 @@ module Graphiti
6
6
  DEFAULT_MAX_PAGE_SIZE = 1_000
7
7
  LINK_MODES = [true, false, :on_demand].freeze
8
8
  BELONGS_TO_RESOURCE_IDS_MODES = [:foreign_key, :always, :never].freeze
9
- BLANK_MODES = [:as_literal, :as_nil, :reject].freeze
9
+ BLANK_MODES = [:literal, :null, :rejected].freeze
10
10
 
11
11
  # Grouped for the ApplicationResource the install generator writes.
12
12
  SETTING_GROUPS = { # :nodoc:
@@ -21,6 +21,7 @@ module Graphiti
21
21
  relationships: {
22
22
  relationships_readable_by_default: {default: true},
23
23
  relationships_writable_by_default: {default: true},
24
+ relationship_placeholders: {default: false, note: "render placeholders for relationships with no ids and no link"},
24
25
  belongs_to_resource_ids_by_default: {
25
26
  default: :foreign_key,
26
27
  values: BELONGS_TO_RESOURCE_IDS_MODES,
@@ -28,10 +29,10 @@ module Graphiti
28
29
  }
29
30
  },
30
31
  filters: {
31
- filters_blanks_by_default: {
32
- default: :as_literal,
32
+ filter_blanks_treated_as: {
33
+ default: :literal,
33
34
  values: BLANK_MODES,
34
- invalid: ->(klass, value) { Errors::InvalidFilterBlanks.new(klass, :filters_blanks_by_default, value) }
35
+ invalid: ->(klass, value) { Errors::InvalidFilterBlanks.new(klass, :filter_blanks_treated_as, value) }
35
36
  }
36
37
  },
37
38
  sorting: {
@@ -194,21 +195,21 @@ module Graphiti
194
195
  end
195
196
 
196
197
  class_methods do
197
- # Deprecated. Both folded into filters_blanks_by_default. Remove in 3.0.
198
+ # Deprecated. Both folded into filter_blanks_treated_as. Remove in 3.0.
198
199
  def filters_accept_nil_by_default
199
- filters_blanks_by_default == :as_nil
200
+ filter_blanks_treated_as == :null
200
201
  end
201
202
 
202
203
  def filters_accept_nil_by_default=(val)
203
- self.filters_blanks_by_default = val ? :as_nil : :as_literal
204
+ self.filter_blanks_treated_as = val ? :null : :literal
204
205
  end
205
206
 
206
207
  def filters_deny_empty_by_default
207
- filters_blanks_by_default == :reject
208
+ filter_blanks_treated_as == :rejected
208
209
  end
209
210
 
210
211
  def filters_deny_empty_by_default=(val)
211
- self.filters_blanks_by_default = val ? :reject : :as_literal
212
+ self.filter_blanks_treated_as = val ? :rejected : :literal
212
213
  end
213
214
 
214
215
  # Deprecated. Renamed to the page_ family. Remove in 3.0.
@@ -416,7 +417,7 @@ module Graphiti
416
417
  end
417
418
  end
418
419
 
419
- blanks_msg = "Use `self.filters_blanks_by_default` (:as_literal, :as_nil, or :reject)"
420
+ blanks_msg = "Use `self.filter_blanks_treated_as` (:literal, :null, or :rejected)"
420
421
  page_msg = "Use `self.page_default_size`, `self.page_max_size` and `self.page_cursors`"
421
422
  DEPRECATOR.deprecate_methods(Configuration::ClassMethods,
422
423
  filters_accept_nil_by_default: blanks_msg,
@@ -208,16 +208,16 @@ module Graphiti
208
208
 
209
209
  def blanks_for(name, opts)
210
210
  if opts.key?(:deny_empty)
211
- DEPRECATOR.warn("The deny_empty: filter option is deprecated. Use blanks: :reject.")
212
- return :reject if opts[:deny_empty]
211
+ DEPRECATOR.warn("The deny_empty: filter option is deprecated. Use blanks: :rejected.")
212
+ return :rejected if opts[:deny_empty]
213
213
  end
214
214
 
215
215
  if opts.key?(:allow_nil)
216
- DEPRECATOR.warn("The allow_nil: filter option is deprecated. Use blanks: :as_nil.")
217
- return opts[:allow_nil] ? :as_nil : :as_literal
216
+ DEPRECATOR.warn("The allow_nil: filter option is deprecated. Use blanks: :null.")
217
+ return opts[:allow_nil] ? :null : :literal
218
218
  end
219
219
 
220
- blanks = opts.fetch(:blanks, filters_blanks_by_default)
220
+ blanks = opts.fetch(:blanks, filter_blanks_treated_as)
221
221
  unless Resource::BLANK_MODES.include?(blanks)
222
222
  raise Errors::InvalidFilterBlanks.new(self, name, blanks)
223
223
  end
@@ -39,7 +39,10 @@ module Graphiti
39
39
  relationship_links != false
40
40
  end
41
41
 
42
+ # true cannot express :on_demand, so it leaves an inherited on-demand mode alone.
42
43
  def autolink=(val)
44
+ return if val && relationship_links == :on_demand
45
+
43
46
  self.relationship_links = val ? true : false
44
47
  end
45
48
 
@@ -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
@@ -361,15 +386,19 @@ module Graphiti
361
386
 
362
387
  def sideload_resource_proxies
363
388
  @sideload_resource_proxies ||= begin
364
- @object = @resource.before_resolve(@object, @query)
365
- 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
366
395
 
367
396
  [].tap do |proxies|
368
- unless @query.sideloads.empty?
369
- @query.sideloads.each_pair do |name, q|
370
- sideload = @resource.class.sideload(name)
371
- next if sideload.nil? || sideload.shared_remote?
372
-
397
+ each_applicable_sideload do |name, sideload, q|
398
+ captured = @resolved_sideload_proxies[name]
399
+ if captured
400
+ proxies.concat(captured)
401
+ else
373
402
  proxies << sideload.build_resource_proxy(results, q, parent_resource)
374
403
  end
375
404
  end
@@ -209,7 +209,7 @@ module Graphiti
209
209
  end
210
210
 
211
211
  def parse_string_null(filter, value)
212
- return value unless filter[:blanks] == :as_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
 
@@ -217,7 +217,7 @@ module Graphiti
217
217
  end
218
218
 
219
219
  def check_blank_filters!(resource, filter, value)
220
- return unless filter.values[0][:blanks] == :reject
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)")
@@ -1,7 +1,5 @@
1
1
  module Graphiti
2
2
  class Serializer < JSONAPI::Serializable::Resource
3
- UNREQUESTED_LINKS = [false, nil, "false"].freeze
4
-
5
3
  include Graphiti::Extensions::BooleanAttribute
6
4
  include Graphiti::Extensions::ExtraAttribute
7
5
  include Graphiti::SerializableHash
@@ -26,6 +24,16 @@ module Graphiti
26
24
  self.relationship_condition_blocks ||= {}
27
25
  self.relationship_sideloads ||= {}
28
26
 
27
+ # Keyed on the sideloads hash itself, which is reassigned whenever a
28
+ # relationship is applied, so the answer is recomputed exactly then.
29
+ def self.on_demand_links?
30
+ sideloads = relationship_sideloads
31
+ return @on_demand_links.last if @on_demand_links&.first.equal?(sideloads)
32
+
33
+ @on_demand_links = [sideloads, sideloads.each_value.any? { |sideload| sideload.link_mode == :on_demand }]
34
+ @on_demand_links.last
35
+ end
36
+
29
37
  def self.inherited(klass)
30
38
  super
31
39
  klass.class_eval do
@@ -76,7 +84,7 @@ module Graphiti
76
84
 
77
85
  def as_jsonapi(kwargs = {})
78
86
  super(**kwargs).tap do |hash|
79
- strip_relationships!(hash) if strip_relationships?
87
+ strip_relationships!(hash)
80
88
  add_links!(hash)
81
89
  end
82
90
  end
@@ -102,21 +110,29 @@ module Graphiti
102
110
  hash[:links] = @resource.links(@object) if @resource.links?
103
111
  end
104
112
 
105
- # A relationship whose only content would be an unrequested on-demand link
106
- # serializes as an empty stub, which JSON:API forbids.
113
+ # The meta: {included: false} stub is jsonapi-serializable's filler, not JSON:API.
107
114
  def strip_relationships!(hash)
115
+ placeholders = relationship_placeholders?
116
+ return if placeholders && !strip_on_demand_relationships?
117
+
108
118
  hash[:relationships]&.reject! do |name, payload|
109
119
  next false if payload.key?(:data) || payload.key?(:links)
120
+ next true unless placeholders
110
121
 
111
122
  self.class.relationship_sideloads[name]&.link_mode == :on_demand
112
123
  end
113
124
  end
114
125
 
115
- def strip_relationships?
116
- context = Graphiti.context[:object]
117
- params = context.params if context.respond_to?(:params)
126
+ # A remote resource exposes a stand-in object rather than a Resource.
127
+ def relationship_placeholders?
128
+ resource_class = @resource.class
129
+ return Resource.relationship_placeholders unless resource_class.respond_to?(:relationship_placeholders)
130
+
131
+ resource_class.relationship_placeholders
132
+ end
118
133
 
119
- UNREQUESTED_LINKS.include?(params && params[:links])
134
+ def strip_on_demand_relationships?
135
+ self.class.on_demand_links? && !@proxy&.query&.render_link?(:on_demand)
120
136
  end
121
137
  end
122
138
  end
@@ -107,18 +107,18 @@ class Graphiti::Sideload::PolymorphicBelongsTo < Graphiti::Sideload::BelongsTo
107
107
  end
108
108
  end
109
109
 
110
- def resolve(parents, query, graph_parent)
110
+ def resolve(parents, query, graph_parent, &proxy_block)
111
111
  if ::Graphiti::Scope.resolve_synchronously?
112
- sync_resolve(parents, query, graph_parent)
112
+ sync_resolve(parents, query, graph_parent, &proxy_block)
113
113
  else
114
- future_resolve(parents, query, graph_parent).value!
114
+ future_resolve(parents, query, graph_parent, &proxy_block).value!
115
115
  end
116
116
  end
117
117
 
118
- def future_resolve(parents, query, graph_parent)
118
+ def future_resolve(parents, query, graph_parent, &proxy_block)
119
119
  promises = []
120
120
  each_resolvable_group(parents, query) do |child, group, child_query|
121
- promises << child.future_resolve(group, child_query, graph_parent)
121
+ promises << child.future_resolve(group, child_query, graph_parent, &proxy_block)
122
122
  end
123
123
  return promises.first if promises.one?
124
124
 
@@ -130,14 +130,14 @@ class Graphiti::Sideload::PolymorphicBelongsTo < Graphiti::Sideload::BelongsTo
130
130
  end
131
131
  end
132
132
 
133
- private
134
-
135
- def sync_resolve(parents, query, graph_parent)
133
+ def sync_resolve(parents, query, graph_parent, &proxy_block)
136
134
  each_resolvable_group(parents, query) do |child, group, child_query|
137
- child.resolve(group, child_query, graph_parent)
135
+ child.sync_resolve(group, child_query, graph_parent, &proxy_block)
138
136
  end
139
137
  end
140
138
 
139
+ private
140
+
141
141
  # Group parents by their polymorphic type and yield each group's child
142
142
  # sideload alongside a query pruned to the sideloads that child supports.
143
143
  def each_resolvable_group(parents, query)
@@ -152,8 +152,12 @@ module Graphiti
152
152
  !!@polymorphic_as
153
153
  end
154
154
 
155
+ # Every check behind the blocker is static sideload configuration, and this
156
+ # is asked once per rendered record per relationship.
155
157
  def resource_ids_from_foreign_key?
156
- resource_ids_blocker.nil?
158
+ return @resource_ids_from_foreign_key unless @resource_ids_from_foreign_key.nil?
159
+
160
+ @resource_ids_from_foreign_key = resource_ids_blocker.nil?
157
161
  end
158
162
 
159
163
  def resource_ids_blocker
@@ -285,14 +289,20 @@ module Graphiti
285
289
  proxy
286
290
  end
287
291
 
288
- def load(parents, query, graph_parent)
292
+ def load(parents, query, graph_parent, &proxy_block)
289
293
  if Scope.resolve_synchronously?
290
- build_resource_proxy(parents, query, graph_parent).to_a
294
+ sync_load(parents, query, graph_parent, &proxy_block)
291
295
  else
292
- future_load(parents, query, graph_parent).value!
296
+ future_load(parents, query, graph_parent, &proxy_block).value!
293
297
  end
294
298
  end
295
299
 
300
+ def sync_load(parents, query, graph_parent, &proxy_block)
301
+ proxy = build_resource_proxy(parents, query, graph_parent)
302
+ proxy_block&.call(proxy)
303
+ proxy.to_a
304
+ end
305
+
296
306
  # Override in subclass
297
307
  def infer_foreign_key
298
308
  model = parent_resource_class.model
@@ -340,15 +350,30 @@ module Graphiti
340
350
  children.replace(associated) if track_associated
341
351
  end
342
352
 
343
- def resolve(parents, query, graph_parent)
353
+ def resolve(parents, query, graph_parent, &proxy_block)
344
354
  if Scope.resolve_synchronously?
345
- sync_resolve(parents, query, graph_parent)
355
+ sync_resolve(parents, query, graph_parent, &proxy_block)
356
+ else
357
+ future_resolve(parents, query, graph_parent, &proxy_block).value!
358
+ end
359
+ end
360
+
361
+ # Called by a scope that already decided to stay inline, so it must not consult the pool again.
362
+ # A scope_proc builds a Scope rather than a proxy, and that nested scope decides for itself.
363
+ def sync_resolve(parents, query, graph_parent, &proxy_block)
364
+ assert_singular!(parents)
365
+
366
+ if self.class.scope_proc
367
+ build_sideload_scope(parents, query, graph_parent).resolve do |sideload_results|
368
+ fire_assign(parents, sideload_results)
369
+ end
346
370
  else
347
- future_resolve(parents, query, graph_parent).value!
371
+ sync_load(parents, query, graph_parent, &proxy_block)
348
372
  end
349
373
  end
350
374
 
351
- def future_resolve(parents, query, graph_parent)
375
+ # A scope_proc builds a Scope rather than a proxy, and a Scope's cache key omits what a proxy's carries.
376
+ def future_resolve(parents, query, graph_parent, &proxy_block)
352
377
  assert_singular!(parents)
353
378
 
354
379
  if self.class.scope_proc
@@ -356,7 +381,7 @@ module Graphiti
356
381
  fire_assign(parents, sideload_results)
357
382
  end
358
383
  else
359
- future_load(parents, query, graph_parent)
384
+ future_load(parents, query, graph_parent, &proxy_block)
360
385
  end
361
386
  end
362
387
 
@@ -420,21 +445,6 @@ module Graphiti
420
445
 
421
446
  private
422
447
 
423
- # Synchronous counterpart to #future_resolve, used when concurrency is off.
424
- # Resolves inline via the synchronous Scope#resolve / #load paths (no
425
- # promises). See Scope#sync_resolve_sideloads.
426
- def sync_resolve(parents, query, graph_parent)
427
- assert_singular!(parents)
428
-
429
- if self.class.scope_proc
430
- build_sideload_scope(parents, query, graph_parent).resolve do |sideload_results|
431
- fire_assign(parents, sideload_results)
432
- end
433
- else
434
- load(parents, query, graph_parent)
435
- end
436
- end
437
-
438
448
  def assert_singular!(parents)
439
449
  if single? && parents.length > 1
440
450
  raise Errors::SingularSideload.new(self, parents.length)
@@ -451,8 +461,9 @@ module Graphiti
451
461
  default_paginate: false
452
462
  end
453
463
 
454
- def future_load(parents, query, graph_parent)
464
+ def future_load(parents, query, graph_parent, &proxy_block)
455
465
  proxy = build_resource_proxy(parents, query, graph_parent)
466
+ proxy_block&.call(proxy)
456
467
  proxy.respond_to?(:future_resolve_data) ? proxy.future_resolve_data : Concurrent::Promises.fulfilled_future(proxy)
457
468
  end
458
469
 
@@ -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(blanks: :as_nil)
189
+ # @example expect(subject).to filter_attribute(:name, :string).with_options(blanks: :null)
190
190
  # @example expect(subject).not_to filter_attribute(:name, :string)
191
191
  def filter_attribute(attribute, type)
192
192
  FilterAttributeMatcher.new(attribute, type)
@@ -1,3 +1,3 @@
1
1
  module Graphiti
2
- VERSION = "2.0.0.beta.10"
2
+ VERSION = "2.0.0.beta.11"
3
3
  end
data/package.json CHANGED
@@ -142,7 +142,7 @@
142
142
  [
143
143
  "@semantic-release/exec",
144
144
  {
145
- "prepareCmd": "bundle exec ruby spec/performance/measure_releases.rb --pending v${nextRelease.version}"
145
+ "prepareCmd": "bundle exec ruby spec/performance/measure_releases.rb --promote v${nextRelease.version}"
146
146
  }
147
147
  ],
148
148
  [
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphiti
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.beta.10
4
+ version: 2.0.0.beta.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lee Richmond