graphiti 2.0.0.beta.6 → 2.0.0.beta.7

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: e108e5f71103c3fb7f38f5328a890ff131b264201c583509081cfd3106a7b255
4
- data.tar.gz: 18edd07b0ab429ec89309ec94164a2324a21bbbfbd6dd37f6e49bb6f9707f1c9
3
+ metadata.gz: acced292af02bc2826f89f6e1d9388bcc7fdedabada42e4cc65d1bfaf9f713f5
4
+ data.tar.gz: 79f966dc31ccb9b3aee687e7dc31a92e943e17845b6017258e366bebee066446
5
5
  SHA512:
6
- metadata.gz: f2affdd800c2966ef15c5d1765b9d1acc48a032c3ee143b9283d69b9545290e227454d77559e25c94b4cd7b0131d0cdc2addbb031a4177155d8085086d818b3b
7
- data.tar.gz: 79c294b775b8be269194cae7e61cf179a16ec8dd01e8b0b24e7a141d3333683bfbd54032e4c07623f6d2dc3fbe3ce6c721884658da89cbe24bfd9dac935f8e07
6
+ metadata.gz: 22584fd2a83e017219fb8202c965d3d295ac341fed01b2e543355c32722cae741866f3188970ee480018d6063c78b06281045f22934c42b9645de72c6cc9562f
7
+ data.tar.gz: 502371f38ad71f5e901b57ba187be70bb4d7c85c94544324e825207bc03f120fa652d0859fe56744efbdbcf826376dad84a4917444c62a7e30193482bca47700
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  graphiti changelog
2
2
 
3
+ # [2.0.0-beta.7](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.6...v2.0.0-beta.7) (2026-08-10)
4
+
5
+
6
+ ### Features
7
+
8
+ * dedupe under concurrency and across distinct resources ([800b1a2](https://github.com/graphiti-api/graphiti/commit/800b1a2bf9bb59702efd00afe8e4f54ae06b500e))
9
+ * deduplicate sideloaded entities across include paths ([0c2829a](https://github.com/graphiti-api/graphiti/commit/0c2829a634e9b96e2403ff168be9a48c6a663c78))
10
+ * deprecate SpecHelpers::Sugar in favor of the full helper names ([b155849](https://github.com/graphiti-api/graphiti/commit/b155849f55fe84b2d0911fa4681d82682dfa39ae))
11
+
3
12
  # [2.0.0-beta.6](https://github.com/graphiti-api/graphiti/compare/v2.0.0-beta.5...v2.0.0-beta.6) (2026-08-09)
4
13
 
5
14
 
@@ -134,7 +134,6 @@ require 'graphiti/spec_helpers/rspec'
134
134
  RSpec.configure do |config|
135
135
  config.include FactoryBot::Syntax::Methods
136
136
  config.include Graphiti::SpecHelpers::RSpec
137
- config.include Graphiti::SpecHelpers::Sugar
138
137
  config.include Graphiti::Rails::TestHelpers, type: :request
139
138
 
140
139
  # Clean your DB between test runs
@@ -163,8 +162,6 @@ These helpers ship with Graphiti, under `Graphiti::SpecHelpers`.
163
162
 
164
163
  ### #jsonapi_data {#jsonapi-data}
165
164
 
166
- > Note: for brevity, this method is aliased to `d`
167
-
168
165
  The `jsonapi_data` method will parse response data and return a normalized object (`Graphiti::SpecHelpers::Node`). Assert against this the same way you assert against JSON:
169
166
 
170
167
  ```ruby
@@ -183,7 +180,7 @@ expect(data.first_name).to eq('Jane')
183
180
  To grab a relationship:
184
181
 
185
182
  ```ruby
186
- sideload = d[0].sideload(:comments)
183
+ sideload = jsonapi_data[0].sideload(:comments)
187
184
  expect(sideload.id).to eq(123)
188
185
  expect(sideload.jsonapi_type).to eq('comments')
189
186
  expect(sideload.body).to eq('body')
@@ -196,7 +193,7 @@ The `sideload` method accepts the *name of the relationship*. It returns a norma
196
193
  To grab a Link:
197
194
 
198
195
  ```ruby
199
- d[0].link(:comments, :related)
196
+ jsonapi_data[0].link(:comments, :related)
200
197
  ```
201
198
 
202
199
  This accepts the relationship name and the link type. It will return the link URL.
@@ -205,28 +202,26 @@ This accepts the relationship name and the link type. It will return the link UR
205
202
 
206
203
  To see the raw JSON response, use `json`.
207
204
 
208
- ### #date and #datetime {#date-and-datetime}
205
+ ### #json_date and #json_datetime {#date-and-datetime}
209
206
 
210
207
  In Graphiti, datetimes are rendered in [ISO 8601 format](https://www.iso.org/iso-8601-date-and-time-format.html). This means that straight date comparisons will fail:
211
208
 
212
209
  ```ruby
213
210
  # WRONG
214
- expect(d[0].created_at).to eq(post.created_at)
211
+ expect(jsonapi_data[0].created_at).to eq(post.created_at)
215
212
  ```
216
213
 
217
- Instead, use the `datetime` helper to convert to ISO 8601 and compare apples to apples:
214
+ Instead, use the `json_datetime` helper to convert to ISO 8601 and compare apples to apples:
218
215
 
219
216
  ```ruby
220
217
  # RIGHT
221
- expect(d[0].created_at).to eq(datetime(post.created_at))
218
+ expect(jsonapi_data[0].created_at).to eq(json_datetime(post.created_at))
222
219
  ```
223
220
 
224
- Similarly, there's a `date` helper as well.
221
+ Similarly, there's a `json_date` helper as well.
225
222
 
226
223
  ### #jsonapi_errors {#jsonapi-errors}
227
224
 
228
- > This method is aliased to `errors` for brevity
229
-
230
225
  To parse an [Errors Payload](http://jsonapi.org/format/#errors):
231
226
 
232
227
  ```ruby
@@ -261,7 +256,7 @@ Resource tests have two helpers, both different ways to execute a query.
261
256
  ```ruby
262
257
  it 'works' do
263
258
  render
264
- expect(d[0].first_name).to eq('Jane')
259
+ expect(jsonapi_data[0].first_name).to eq('Jane')
265
260
  json # => { data: { type: 'employees', ... } }
266
261
  end
267
262
  ```
@@ -275,6 +270,33 @@ it 'works' do
275
270
  end
276
271
  ```
277
272
 
273
+ ### Resource Matchers {#resource-matchers}
274
+
275
+ For one-line assertions about a Resource's shape, use the built-in matchers. They're included automatically in `type: :resource` specs and expect a Resource instance as the subject:
276
+
277
+ ```ruby
278
+ RSpec.describe PostResource, type: :resource do
279
+ subject { described_class.new }
280
+
281
+ it { is_expected.to belong_to_resource(:author) }
282
+ it { is_expected.to have_many_resources(:comments) }
283
+ it { is_expected.to have_one_resource(:detail) }
284
+ it { is_expected.to expose_attribute(:title, :string) }
285
+ it { is_expected.to filter_attribute(:title, :string) }
286
+ end
287
+ ```
288
+
289
+ Each matcher accepts `with_options` to assert configuration:
290
+
291
+ ```ruby
292
+ it do
293
+ is_expected.to belong_to_resource(:author)
294
+ .with_options(foreign_key: :author_id, resource: AuthorResource)
295
+ end
296
+
297
+ it { is_expected.to expose_attribute(:title, :string).with_options(writable: false) }
298
+ ```
299
+
278
300
  ### API Test Helpers {#api-test-helpers}
279
301
 
280
302
  When executing an API test request, always use the `jsonapi_` doppelgänger:
@@ -378,7 +400,7 @@ describe 'filtering' do
378
400
 
379
401
  it 'works' do
380
402
  render
381
- expect(d.map(&:id)).to eq([employee2.id])
403
+ expect(jsonapi_data.map(&:id)).to eq([employee2.id])
382
404
  end
383
405
  end
384
406
  end
@@ -401,7 +423,7 @@ describe 'sorting' do
401
423
 
402
424
  it 'works' do
403
425
  render
404
- expect(d.map(&:id)).to eq([
426
+ expect(jsonapi_data.map(&:id)).to eq([
405
427
  employee1.id,
406
428
  employee2.id
407
429
  ])
@@ -415,7 +437,7 @@ describe 'sorting' do
415
437
 
416
438
  it 'works' do
417
439
  render
418
- expect(d.map(&:id)).to eq([
440
+ expect(jsonapi_data.map(&:id)).to eq([
419
441
  employee2.id,
420
442
  employee1.id
421
443
  ])
@@ -447,7 +469,7 @@ describe 'sideloading' do
447
469
 
448
470
  it 'returns position with historical index == 1' do
449
471
  render
450
- sl = d[0].sideload(:current_position)
472
+ sl = jsonapi_data[0].sideload(:current_position)
451
473
  expect(sl.jsonapi_type).to eq('positions')
452
474
  expect(sl.id).to eq(pos2.id)
453
475
  end
@@ -669,9 +691,9 @@ RSpec.describe "employees#index", type: :request do
669
691
  expect(EmployeeResource).to receive(:all).and_call_original
670
692
  make_request
671
693
  expect(response.status).to eq(200)
672
- expect(d.map(&:jsonapi_type).uniq)
694
+ expect(jsonapi_data.map(&:jsonapi_type).uniq)
673
695
  .to match_array(['employees'])
674
- expect(d.map(&:id))
696
+ expect(jsonapi_data.map(&:id))
675
697
  .to match_array([employee1.id, employee2.id])
676
698
  end
677
699
  end
@@ -694,8 +716,8 @@ describe 'basic fetch' do
694
716
  expect(EmployeeResource).to receive(:find).and_call_original
695
717
  make_request
696
718
  expect(response.status).to eq(200)
697
- expect(d.jsonapi_type).to eq('employees')
698
- expect(d.id).to eq(employee.id)
719
+ expect(jsonapi_data.jsonapi_type).to eq('employees')
720
+ expect(jsonapi_data.id).to eq(employee.id)
699
721
  end
700
722
  end
701
723
  ```
@@ -843,7 +865,7 @@ it 'works' do
843
865
  Graphiti.with_context ctx do
844
866
  render
845
867
  end
846
- expect(d[0].salary).to eq(100_000)
868
+ expect(jsonapi_data[0].salary).to eq(100_000)
847
869
  end
848
870
  ```
849
871
 
data/docs/upgrading.md CHANGED
@@ -214,6 +214,7 @@ Every name below still works, warns, and will be removed in the next major. They
214
214
  | --- | --- |
215
215
  | `require "graphiti_spec_helpers/rspec"` | `require "graphiti/spec_helpers/rspec"` |
216
216
  | `GraphitiSpecHelpers::RSpec` / `::Sugar` / `::Errors::*` | `Graphiti::SpecHelpers::*` |
217
+ | `include Graphiti::SpecHelpers::Sugar` (`d`, `included`, `errors`, `dt`) | call `jsonapi_data`, `jsonapi_included`, `jsonapi_errors`, `json_datetime` directly |
217
218
  | `require "graphiti-rails"` | remove / no longer needed |
218
219
  | `include Graphiti::Rails` | `include Graphiti::Rails::Controller`|
219
220
  | `include Graphiti::Responders` | `include Graphiti::Rails::Responders` |
@@ -49,7 +49,6 @@ module Graphiti
49
49
 
50
50
  RSpec.configure do |config|
51
51
  config.include Graphiti::SpecHelpers::RSpec
52
- config.include Graphiti::SpecHelpers::Sugar
53
52
  end
54
53
 
55
54
  Graphiti::SpecHelpers::RSpec.schema!
@@ -15,8 +15,8 @@ RSpec.describe "<%= type %>#index", type: :request do
15
15
  expect(<%= resource_class %>).to receive(:all).and_call_original
16
16
  make_request
17
17
  expect(response.status).to eq(200), response.body
18
- expect(d.map(&:jsonapi_type).uniq).to match_array(['<%= type %>'])
19
- expect(d.map(&:<%= id_or_rawid %>)).to match_array([<%= var %>1.id, <%= var %>2.id])
18
+ expect(jsonapi_data.map(&:jsonapi_type).uniq).to match_array(['<%= type %>'])
19
+ expect(jsonapi_data.map(&:<%= id_or_rawid %>)).to match_array([<%= var %>1.id, <%= var %>2.id])
20
20
  end
21
21
  end
22
22
  end
@@ -11,7 +11,7 @@ RSpec.describe <%= resource_class %>, type: :resource do
11
11
  expect(data.jsonapi_type).to eq('<%= type %>')
12
12
  <%- attributes.each do |a| -%>
13
13
  <%- if [:created_at, :updated_at].include?(a.name.to_sym) -%>
14
- expect(data.<%= a.name %>).to eq(datetime(<%= file_name %>.<%= a.name %>))
14
+ expect(data.<%= a.name %>).to eq(json_datetime(<%= file_name %>.<%= a.name %>))
15
15
  <%- else -%>
16
16
  expect(data.<%= a.name %>).to eq(<%= file_name %>.<%= a.name %>)
17
17
  <%- end -%>
@@ -31,7 +31,7 @@ RSpec.describe <%= resource_class %>, type: :resource do
31
31
 
32
32
  it 'works' do
33
33
  render
34
- expect(d.map(&:<%= id_or_rawid %>)).to eq([<%= var %>2.id])
34
+ expect(jsonapi_data.map(&:<%= id_or_rawid %>)).to eq([<%= var %>2.id])
35
35
  end
36
36
  end
37
37
  end
@@ -48,7 +48,7 @@ RSpec.describe <%= resource_class %>, type: :resource do
48
48
 
49
49
  it 'works' do
50
50
  render
51
- expect(d.map(&:<%= id_or_rawid %>)).to eq([
51
+ expect(jsonapi_data.map(&:<%= id_or_rawid %>)).to eq([
52
52
  <%= var %>1.id,
53
53
  <%= var %>2.id
54
54
  ]<%= sort_raw_ids %>)
@@ -62,7 +62,7 @@ RSpec.describe <%= resource_class %>, type: :resource do
62
62
 
63
63
  it 'works' do
64
64
  render
65
- expect(d.map(&:<%= id_or_rawid %>)).to eq([
65
+ expect(jsonapi_data.map(&:<%= id_or_rawid %>)).to eq([
66
66
  <%= var %>2.id,
67
67
  <%= var %>1.id
68
68
  ]<%= sort_raw_ids_descending %>)
@@ -14,8 +14,8 @@ RSpec.describe "<%= type %>#show", type: :request do
14
14
  expect(<%= resource_class %>).to receive(:find).and_call_original
15
15
  make_request
16
16
  expect(response.status).to eq(200)
17
- expect(d.jsonapi_type).to eq('<%= type %>')
18
- expect(d.<%= id_or_rawid %>).to eq(<%= var %>.id)
17
+ expect(jsonapi_data.jsonapi_type).to eq('<%= type %>')
18
+ expect(jsonapi_data.<%= id_or_rawid %>).to eq(<%= var %>.id)
19
19
  end
20
20
  end
21
21
  end
@@ -4,8 +4,15 @@ module Graphiti
4
4
  class Query
5
5
  attr_reader :resource, :association_name, :params, :action
6
6
 
7
- # TODO: make the trailing optionals keywords once the satellite gems are rolled in - see Runner#query for the nil-padding this forces
8
- def initialize(resource, params, association_name = nil, nested_include = nil, parents = [], action = nil)
7
+ def initialize(resource, params, *positional, association_name: nil, nested_include: nil, parents: [], action: nil)
8
+ if positional.any?
9
+ Graphiti::DEPRECATOR.warn("Passing Query.new trailing arguments positionally is deprecated. Use association_name:/nested_include:/parents:/action: keywords.")
10
+ association_name ||= positional[0]
11
+ nested_include ||= positional[1]
12
+ parents = positional[2] if positional.length > 2
13
+ action ||= positional[3]
14
+ end
15
+
9
16
  @resource = resource
10
17
  @association_name = association_name
11
18
  @params = params
@@ -21,6 +28,13 @@ module Graphiti
21
28
  !!@association_name
22
29
  end
23
30
 
31
+ # Concurrent::Map because sideload scopes resolve on pool threads
32
+ def entity_map
33
+ return root.entity_map unless root == self
34
+
35
+ @entity_map ||= Concurrent::Map.new
36
+ end
37
+
24
38
  def top_level?
25
39
  !association?
26
40
  end
@@ -110,9 +124,10 @@ module Graphiti
110
124
  relationship_name = sideload ? sideload.name : key
111
125
  hash[relationship_name] = Query.new sl_resource,
112
126
  @params,
113
- key,
114
- sub_hash,
115
- query_parents, :all
127
+ association_name: key,
128
+ nested_include: sub_hash,
129
+ parents: query_parents,
130
+ action: :all
116
131
  else
117
132
  handle_missing_sideload(key)
118
133
  end
@@ -124,6 +139,10 @@ module Graphiti
124
139
  @parents ||= []
125
140
  end
126
141
 
142
+ def root
143
+ parents.first || self
144
+ end
145
+
127
146
  def fields
128
147
  @fields ||= begin
129
148
  hash = parse_fieldset(@params[:fields] || {})
@@ -17,7 +17,7 @@ module Graphiti
17
17
 
18
18
  # @api private
19
19
  def _all(params, opts, base_scope)
20
- runner = Runner.new(self, params, opts.delete(:query), :all)
20
+ runner = Runner.new(self, params, query: opts.delete(:query), action: :all)
21
21
  opts[:params] = params
22
22
  runner.proxy(base_scope, opts.merge(caching_options))
23
23
  end
@@ -36,7 +36,7 @@ module Graphiti
36
36
  params[:filter] ||= {}
37
37
  params[:filter][:id] = id if id
38
38
 
39
- runner = Runner.new(self, params, nil, :find)
39
+ runner = Runner.new(self, params, action: :find)
40
40
 
41
41
  find_options = {
42
42
  single: true,
@@ -56,7 +56,7 @@ module Graphiti
56
56
  # Wrap models fetched outside graphiti so they render like any other proxy
57
57
  def wrap(models, base_scope = nil)
58
58
  validate_wrap_models!(models)
59
- runner = Runner.new(self, {}, nil, :find)
59
+ runner = Runner.new(self, {}, action: :find)
60
60
  runner.proxy(base_scope, bypass_required_filters: true).tap do |proxy|
61
61
  proxy.data = models
62
62
  end
@@ -3,8 +3,13 @@ module Graphiti
3
3
  attr_reader :params
4
4
  attr_reader :deserialized_payload
5
5
 
6
- # TODO: make query and action keywords once the satellite gems are rolled in - they instantiate Runner positionally
7
- def initialize(resource_class, params, query = nil, action = nil)
6
+ def initialize(resource_class, params, *positional, query: nil, action: nil)
7
+ if positional.any?
8
+ Graphiti::DEPRECATOR.warn("Passing query/action to Runner.new positionally is deprecated. Use query:/action: keywords.")
9
+ query ||= positional[0]
10
+ action ||= positional[1]
11
+ end
12
+
8
13
  @resource_class = resource_class
9
14
  @params = params
10
15
  @query = query
@@ -30,7 +35,7 @@ module Graphiti
30
35
  end
31
36
 
32
37
  def query
33
- @query ||= Query.new(jsonapi_resource, params, nil, nil, [], @action)
38
+ @query ||= Query.new(jsonapi_resource, params, action: @action)
34
39
  end
35
40
 
36
41
  def query_hash
@@ -154,12 +154,25 @@ module Graphiti
154
154
  payload[:results]
155
155
  }
156
156
  resolved.compact!
157
+ deduplicate_entities!(resolved)
157
158
  assign_serializer(resolved)
158
159
  yield resolved if block_given?
159
160
  @opts[:after_resolve]&.call(resolved)
160
161
  resolved
161
162
  end
162
163
 
164
+ # Must run before sideloads assign, so every include path populates the
165
+ # canonical instance. The resource class in the key keeps two resources
166
+ # serving the same model from sharing an instance and a serializer.
167
+ def deduplicate_entities!(resolved)
168
+ resolved.map! do |record|
169
+ next record unless record.respond_to?(:id) && !record.id.nil?
170
+
171
+ key = [@resource.class, record.class, record.id]
172
+ @query.entity_map.compute_if_absent(key) { record }
173
+ end
174
+ end
175
+
163
176
  def each_applicable_sideload
164
177
  @query.sideloads.each_pair do |name, sideload_query|
165
178
  sideload = @resource.class.sideload(name)
@@ -26,6 +26,11 @@ module Graphiti
26
26
  end
27
27
 
28
28
  module Sugar
29
+ def self.included(base)
30
+ Graphiti::DEPRECATOR.warn("Graphiti::SpecHelpers::Sugar is deprecated. Call jsonapi_data, jsonapi_included, jsonapi_errors, json_date and json_datetime directly.")
31
+ super
32
+ end
33
+
29
34
  def d
30
35
  jsonapi_data
31
36
  end
@@ -1,3 +1,3 @@
1
1
  module Graphiti
2
- VERSION = "2.0.0.beta.6"
2
+ VERSION = "2.0.0.beta.7"
3
3
  end
data/package.json CHANGED
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "release": {
24
24
  "branches": [
25
+ "1.x",
25
26
  "main",
26
27
  {
27
28
  "name": "beta",
data/website/sidebars.js CHANGED
@@ -21,18 +21,18 @@ module.exports = {
21
21
  type: 'category',
22
22
  label: 'Topics',
23
23
  items: [
24
- 'topics/authorization',
25
- 'topics/error-handling',
26
24
  'topics/testing',
25
+ 'topics/error-handling',
26
+ 'topics/authorization',
27
27
  'topics/debugging',
28
28
  'topics/caching',
29
29
  'topics/etags',
30
30
  'topics/json-attributes',
31
- 'topics/remote-resources',
31
+ 'topics/customizing-sideloads',
32
32
  'concepts/backends-and-models',
33
33
  'topics/without-activerecord',
34
34
  'topics/openstruct-models',
35
- 'topics/customizing-sideloads',
35
+ 'topics/remote-resources',
36
36
  'topics/hopping-relationships',
37
37
  ],
38
38
  },
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.6
4
+ version: 2.0.0.beta.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lee Richmond