graphiti 1.11.1 → 1.12.1

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: 86630b0954711272a5cb2d37e3ba983eea16b580626fe6ef37479b5cbe1ef26b
4
- data.tar.gz: 331e7c4a78b57a7362986bd0f49348cee046df52683e0f27e82564d8ff4fa3e9
3
+ metadata.gz: 96af714433c7faece37e74ab67dad245d5e03396ec391be80cec77622d231faf
4
+ data.tar.gz: 34ce7b027042cd3cda4cf656ba8d96a05908dc0edbe9104ee4fb3597c5970971
5
5
  SHA512:
6
- metadata.gz: b6ee44eadb2f7e6a7c37801326557a1e88bd6921dc5aca76cbd94fe4159bd8d54bfc617c92086b9dc7d9bbdcc3d5925d846213f334dbe5f25726394842acb50a
7
- data.tar.gz: c1c0e49eb82a6afde05dac7628175e582590fc17fb04bf2aabe64050a31a5870e3f1dfcd1e07d21186635ae923cad1b6b51e4d811d9e86f1437576389da13d99
6
+ metadata.gz: 3d0c414812bbccef7567e2e8e48e6b6353582d7801aa71a893f368eb12c71906e1420a1f40dd6d18c8ad6e5d43e3585983340d409742303112dd2737810a26d1
7
+ data.tar.gz: 59774b04ced36164343ddb12a7f2aa2026d2981b0c9df1b6bb92f127abb856467403e64cf72ec15bfde012511f1fa5908bf38bbbecea1becc2527d61df346d72
@@ -4,6 +4,7 @@ on:
4
4
  push:
5
5
  branches: [main]
6
6
  pull_request: {}
7
+ workflow_dispatch: {}
7
8
 
8
9
  concurrency:
9
10
  group: ci-${{ github.head_ref || github.ref }}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  graphiti changelog
2
2
 
3
+ ## [1.12.1](https://github.com/graphiti-api/graphiti/compare/v1.12.0...v1.12.1) (2026-07-29)
4
+
5
+
6
+ ### Performance Improvements
7
+
8
+ * skip promise machinery when concurrency is disabled ([#510](https://github.com/graphiti-api/graphiti/issues/510)) ([b2e5f37](https://github.com/graphiti-api/graphiti/commit/b2e5f37428f7628b9fadd03823bd68552e125af0)), closes [#472](https://github.com/graphiti-api/graphiti/issues/472) [#497](https://github.com/graphiti-api/graphiti/issues/497) [#505](https://github.com/graphiti-api/graphiti/issues/505)
9
+
10
+ # [1.12.0](https://github.com/graphiti-api/graphiti/compare/v1.11.1...v1.12.0) (2026-07-29)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * GQL name chaining ([#415](https://github.com/graphiti-api/graphiti/issues/415)) [skip ci] ([930928a](https://github.com/graphiti-api/graphiti/commit/930928a8779037efbcc1989eef5cf579c608e431))
16
+
17
+
18
+ ### Features
19
+
20
+ * conditional relationships evaluate readable/writable guards per-request ([#450](https://github.com/graphiti-api/graphiti/issues/450)) ([3ed3187](https://github.com/graphiti-api/graphiti/commit/3ed3187068d268cd85ee1c36dc8135aa92abd05c))
21
+ * pass the model and attribute name to writable guards ([#511](https://github.com/graphiti-api/graphiti/issues/511)) [skip ci] ([24a8a94](https://github.com/graphiti-api/graphiti/commit/24a8a94f801867f57cc7c1ae9c6a9bdbfec05c24))
22
+
3
23
  ## [1.11.1](https://github.com/graphiti-api/graphiti/compare/v1.11.0...v1.11.1) (2026-07-28)
4
24
 
5
25
  # [1.11.0](https://github.com/graphiti-api/graphiti/compare/v1.10.3...v1.11.0) (2026-07-28)
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,77 @@
1
+ # Contributing
2
+
3
+ ## Commit messages
4
+
5
+ Releases are cut automatically by [semantic-release](https://github.com/semantic-release/semantic-release)
6
+ from commit messages, so the prefix you use decides whether a release happens
7
+ and how the version changes.
8
+
9
+ Format: `type: subject`, or `type(scope): subject`.
10
+
11
+ ### Types that release
12
+
13
+ | Prefix | Version bump | Use for |
14
+ | ------ | ------------ | ------- |
15
+ | `fix:` | patch (`1.2.3` → `1.2.4`) | A bug fix users would notice |
16
+ | `perf:` | patch | A performance improvement |
17
+ | `feat:` | minor (`1.2.3` → `1.3.0`) | New functionality |
18
+
19
+ ### Types that do not release
20
+
21
+ `build:` `chore:` `ci:` `docs:` `refactor:` `style:` `test:`
22
+
23
+ These land on `main` without publishing a gem. Use them for anything with no
24
+ user-facing change — test fixes, CI tweaks, internal refactors. They are still
25
+ included in the next release's notes.
26
+
27
+ ### Breaking changes
28
+
29
+ Either add `!` after the type, or a `BREAKING CHANGE:` footer. Both produce a
30
+ major bump (`1.2.3` → `2.0.0`):
31
+
32
+ ```
33
+ feat!: remove deprecated sideload API
34
+ ```
35
+
36
+ ```
37
+ feat: replace sideload API
38
+
39
+ BREAKING CHANGE: `allow_sideload` no longer accepts a :scope option.
40
+ ```
41
+
42
+ The `!` works with a scope too: `feat(adapters)!: ...`
43
+
44
+ Describe what breaks and what to do instead — the footer text is published in
45
+ the release notes verbatim.
46
+
47
+ ### Notes
48
+
49
+ - Only the **commit** prefix matters, not the PR title, unless the PR is
50
+ squash-merged with the title as the commit subject.
51
+ - The prefix must be lowercase and followed by `: ` — `Fix: thing` and
52
+ `fix:thing` are both unparsed, and an unparsed commit never triggers a
53
+ release.
54
+ - If nothing since the last tag is `fix:`, `perf:`, `feat:`, or breaking, no
55
+ release is cut. That is expected.
56
+
57
+ ## Running the tests
58
+
59
+ ```sh
60
+ bundle exec rspec
61
+ ```
62
+
63
+ The Rails integration specs are gated behind an environment variable and are
64
+ **silently skipped** without it — a plain `rspec` run reports success while
65
+ covering none of the ActiveRecord adapter:
66
+
67
+ ```sh
68
+ BUNDLE_GEMFILE=gemfiles/rails_8_0_graphiti_rails.gemfile \
69
+ APPRAISAL_INITIALIZED=1 \
70
+ bundle exec rspec spec/integration/rails/
71
+ ```
72
+
73
+ Lint with:
74
+
75
+ ```sh
76
+ bundle exec standardrb
77
+ ```
data/graphiti.gemspec CHANGED
@@ -18,6 +18,24 @@ Gem::Specification.new do |spec|
18
18
  spec.require_paths = ["lib"]
19
19
  spec.required_ruby_version = ">= 2.7"
20
20
 
21
+ # TODO: remove once the 1.12 upgrade window has passed
22
+ spec.post_install_message = <<~MSG
23
+ Graphiti: relationship readable:/writable: guards are now enforced.
24
+
25
+ Symbols, strings, and procs passed to a relationship's readable/writable
26
+ guards were always interpreted as true and never actually called before this
27
+ conditional relationship change. They are now evaluated per-request, where
28
+ unreadable relationships are omitted from responses and includes, and unwritable
29
+ relationships reject writes.
30
+
31
+ To list every affected relationship in your app:
32
+
33
+ bin/rails runner 'puts Graphiti.guarded_relationships'
34
+
35
+ Audit these before deploying. Apps using schema.json will also see affected
36
+ relationships flagged as "became guarded" by the schema check.
37
+ MSG
38
+
21
39
  spec.add_dependency "jsonapi-serializable", "~> 0.3.0"
22
40
  spec.add_dependency "jsonapi-renderer", "~> 0.2", ">= 0.2.2"
23
41
  spec.add_dependency "dry-types", ">= 0.15.0", "< 2.0"
@@ -70,13 +70,13 @@ module Graphiti
70
70
 
71
71
  serializers = v.send(:resources)
72
72
  name = graphql ? k.to_s.camelize(:lower) : k
73
- name_chain = name_chain.dup
74
- name_chain << k unless name_chain.last == k
73
+ subchain = name_chain.dup
74
+ subchain << k unless subchain.last == k
75
75
 
76
76
  unless remote_resource? && serializers.nil?
77
77
  payload = if serializers.is_a?(Array)
78
78
  data = serializers.map { |rr|
79
- rr.to_hash(fields: fields, include: nested_include, graphql: graphql, name_chain: name_chain)
79
+ rr.to_hash(fields: fields, include: nested_include, graphql: graphql, name_chain: subchain)
80
80
  }
81
81
  graphql ? {nodes: data} : data
82
82
  elsif serializers.nil?
@@ -86,7 +86,7 @@ module Graphiti
86
86
  end
87
87
  end
88
88
  else
89
- serializers.to_hash(fields: fields, include: nested_include, graphql: graphql, name_chain: name_chain)
89
+ serializers.to_hash(fields: fields, include: nested_include, graphql: graphql, name_chain: subchain)
90
90
  end
91
91
 
92
92
  attrs[name.to_sym] = payload
@@ -211,10 +211,13 @@ module Graphiti
211
211
  allowlist = allowlist[@resource.context_namespace] if allowlist
212
212
  end
213
213
 
214
- allowlist ? Util::IncludeParams.scrub(requested, allowlist) : requested
215
- end
214
+ scrubbed = allowlist ? Util::IncludeParams.scrub(requested, allowlist) : requested
216
215
 
217
- @include_hash
216
+ scrubbed.filter do |key, value|
217
+ sideload = @resource.class.sideload(key)
218
+ sideload.nil? ? true : sideload.readable?
219
+ end
220
+ end
218
221
  end
219
222
 
220
223
  def stats
@@ -83,20 +83,22 @@ module Graphiti
83
83
  end
84
84
 
85
85
  def typecast_attributes(resource, attributes, action, payload_path)
86
- attributes.each_pair do |key, value|
87
- # Only validate id if create action, otherwise it's only used for lookup
88
- next if action != :create &&
89
- key == :id &&
90
- resource.class.config[:attributes][:id][:writable] == false
91
-
92
- begin
93
- attributes[key] = resource.typecast(key, value, :writable)
94
- rescue Graphiti::Errors::UnknownAttribute
95
- @errors.add(fully_qualified_key(key, payload_path), :unknown_attribute, message: "is an unknown attribute")
96
- rescue Graphiti::Errors::InvalidAttributeAccess
97
- @errors.add(fully_qualified_key(key, payload_path), :unwritable_attribute, message: "cannot be written")
98
- rescue Graphiti::Errors::TypecastFailed => e
99
- @errors.add(fully_qualified_key(key, payload_path), :type_error, message: "should be type #{e.type_name}")
86
+ resource.with_guarded_write(action, attributes[:id]) do
87
+ attributes.each_pair do |key, value|
88
+ # Only validate id if create action, otherwise it's only used for lookup
89
+ next if action != :create &&
90
+ key == :id &&
91
+ resource.class.config[:attributes][:id][:writable] == false
92
+
93
+ begin
94
+ attributes[key] = resource.typecast(key, value, :writable)
95
+ rescue Graphiti::Errors::UnknownAttribute
96
+ @errors.add(fully_qualified_key(key, payload_path), :unknown_attribute, message: "is an unknown attribute")
97
+ rescue Graphiti::Errors::InvalidAttributeAccess
98
+ @errors.add(fully_qualified_key(key, payload_path), :unwritable_attribute, message: "cannot be written")
99
+ rescue Graphiti::Errors::TypecastFailed => e
100
+ @errors.add(fully_qualified_key(key, payload_path), :type_error, message: "should be type #{e.type_name}")
101
+ end
100
102
  end
101
103
  end
102
104
  end
@@ -67,6 +67,32 @@ module Graphiti
67
67
  adapter.base_scope(model)
68
68
  end
69
69
 
70
+ # Exposes write context to attribute guards for the duration of the block.
71
+ # @api private
72
+ def with_guarded_write(action, id)
73
+ @guarded_write = {action: action, id: id}
74
+ yield
75
+ ensure
76
+ remove_instance_variable(:@guarded_write) if defined?(@guarded_write)
77
+ remove_instance_variable(:@guard_model) if defined?(@guard_model)
78
+ end
79
+
80
+ # The model a writable guard is being asked about. Resolved lazily and
81
+ # memoized, so it costs nothing unless a guard asks for it, and is only
82
+ # resolved once per payload. On create, this is a new unsaved instance.
83
+ # @api private
84
+ def guard_model
85
+ return @guard_model if defined?(@guard_model)
86
+
87
+ @guard_model = if !defined?(@guarded_write)
88
+ nil
89
+ elsif @guarded_write[:action] == :create || @guarded_write[:id].nil?
90
+ build(model)
91
+ else
92
+ self.class._find(id: @guarded_write[:id]).data
93
+ end
94
+ end
95
+
70
96
  def typecast(name, value, flag)
71
97
  att = get_attr!(name, flag, request: true)
72
98
  type_name = att[:type]
@@ -247,6 +247,10 @@ module Graphiti
247
247
  schema[:single] = true
248
248
  end
249
249
 
250
+ if config.guarded?
251
+ schema[:guard] = true
252
+ end
253
+
250
254
  r[name] = schema
251
255
  end
252
256
  end
@@ -102,6 +102,10 @@ module Graphiti
102
102
  next
103
103
  end
104
104
 
105
+ if new_rel[:guard] && !old_rel[:guard]
106
+ @errors << "#{old_resource[:name]}: relationship #{name.inspect} became guarded."
107
+ end
108
+
105
109
  if new_rel[:resource] != old_rel[:resource]
106
110
  @errors << "#{old_resource[:name]}: relationship #{name.inspect} changed resource from #{old_rel[:resource]} to #{new_rel[:resource]}."
107
111
  end
@@ -42,11 +42,20 @@ module Graphiti
42
42
  }
43
43
  end
44
44
 
45
- def resolve
45
+ 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.
50
+ # See https://github.com/graphiti-api/graphiti/issues/505
51
+ return sync_resolve(&blk) unless Graphiti.config.concurrency
52
+
46
53
  future_resolve.value!
47
54
  end
48
55
 
49
56
  def resolve_sideloads(results)
57
+ return sync_resolve_sideloads(results) unless Graphiti.config.concurrency
58
+
50
59
  future_resolve_sideloads(results).value!
51
60
  end
52
61
 
@@ -113,6 +122,43 @@ module Graphiti
113
122
 
114
123
  private
115
124
 
125
+ # Synchronous counterpart to #future_resolve, used when concurrency is off.
126
+ # Resolves the resource and its sideloads inline without any promise
127
+ # machinery. See #resolve.
128
+ def sync_resolve
129
+ return [] if @query.zero_results?
130
+
131
+ resolved = broadcast_data { |payload|
132
+ @object = @resource.before_resolve(@object, @query)
133
+ payload[:results] = @resource.resolve(@object)
134
+ payload[:results]
135
+ }
136
+ resolved.compact!
137
+ assign_serializer(resolved)
138
+ yield resolved if block_given?
139
+ @opts[:after_resolve]&.call(resolved)
140
+ sync_resolve_sideloads(resolved) unless @query.sideloads.empty?
141
+ resolved
142
+ end
143
+
144
+ # Synchronous counterpart to #future_resolve_sideloads, used when
145
+ # concurrency is off. Resolves each sideload inline. See #resolve_sideloads.
146
+ def sync_resolve_sideloads(results)
147
+ return if results == []
148
+
149
+ @query.sideloads.each_pair do |name, q|
150
+ sideload = @resource.class.sideload(name)
151
+ next if sideload.nil? || sideload.shared_remote?
152
+
153
+ Graphiti.config.before_sideload&.call(Graphiti.context)
154
+ sideload.resolve(results, q, @resource)
155
+ end
156
+
157
+ # Match pre-1.8 semantics: the non-concurrent resolve_sideloads returned
158
+ # nil (not the sideloads Hash). Callers don't rely on the return value.
159
+ nil
160
+ end
161
+
116
162
  def future_resolve_sideloads(results)
117
163
  return Concurrent::Promises.fulfilled_future(nil, self.class.global_thread_pool_executor) if results == []
118
164
 
@@ -28,6 +28,9 @@ module Graphiti
28
28
  prev = Util::Hash.deep_dup(field_condition_blocks)
29
29
  super
30
30
  self.field_condition_blocks = prev
31
+ # _register_condition mutates the hash, which ancestors share by
32
+ # reference until reassigned - copy first so the guard doesn't leak.
33
+ self.relationship_condition_blocks = Util::Hash.deep_dup(relationship_condition_blocks)
31
34
  _register_condition(relationship_condition_blocks, name, options)
32
35
  end
33
36
 
@@ -108,7 +108,20 @@ class Graphiti::Sideload::PolymorphicBelongsTo < Graphiti::Sideload::BelongsTo
108
108
  end
109
109
 
110
110
  def resolve(parents, query, graph_parent)
111
- future_resolve(parents, query, graph_parent).value!
111
+ return future_resolve(parents, query, graph_parent).value! if Graphiti.config.concurrency
112
+
113
+ parents.group_by(&grouper.field_name).each_pair do |group_name, group|
114
+ next if group_name.nil? || grouper.ignore?(group_name)
115
+
116
+ match = ->(c) { c.group_name == group_name.to_sym }
117
+ if (sideload = children.values.find(&match))
118
+ duped = remove_invalid_sideloads(sideload.resource, query)
119
+ sideload.resolve(group, duped, graph_parent)
120
+ else
121
+ err = ::Graphiti::Errors::PolymorphicSideloadChildNotFound
122
+ raise err.new(self, group_name)
123
+ end
124
+ end
112
125
  end
113
126
 
114
127
  def future_resolve(parents, query, graph_parent)
@@ -99,11 +99,15 @@ module Graphiti
99
99
  end
100
100
 
101
101
  def readable?
102
- !!@readable
102
+ evaluate_flag @readable
103
103
  end
104
104
 
105
105
  def writable?
106
- !!@writable
106
+ evaluate_flag @writable
107
+ end
108
+
109
+ def guarded?
110
+ dynamic_flag?(@readable) || dynamic_flag?(@writable)
107
111
  end
108
112
 
109
113
  def single?
@@ -236,6 +240,8 @@ module Graphiti
236
240
  end
237
241
 
238
242
  def load(parents, query, graph_parent)
243
+ return build_resource_proxy(parents, query, graph_parent).to_a unless Graphiti.config.concurrency
244
+
239
245
  future_load(parents, query, graph_parent).value!
240
246
  end
241
247
 
@@ -286,6 +292,33 @@ module Graphiti
286
292
  children.replace(associated) if track_associated
287
293
  end
288
294
 
295
+ # Synchronous counterpart to #future_resolve, used when concurrency is off.
296
+ # Mirrors #future_resolve but resolves inline via the synchronous
297
+ # Scope#resolve / #load paths (no promises). See Scope#sync_resolve_sideloads.
298
+ def resolve(parents, query, graph_parent)
299
+ return future_resolve(parents, query, graph_parent).value! if Graphiti.config.concurrency
300
+
301
+ if single? && parents.length > 1
302
+ raise Errors::SingularSideload.new(self, parents.length)
303
+ end
304
+
305
+ if self.class.scope_proc
306
+ sideload_scope = fire_scope(parents)
307
+ sideload_scope = Scope.new sideload_scope,
308
+ resource,
309
+ query,
310
+ parent: graph_parent,
311
+ sideload: self,
312
+ sideload_parent_length: parents.length,
313
+ default_paginate: false
314
+ sideload_scope.resolve do |sideload_results|
315
+ fire_assign(parents, sideload_results)
316
+ end
317
+ else
318
+ load(parents, query, graph_parent)
319
+ end
320
+ end
321
+
289
322
  def future_resolve(parents, query, graph_parent)
290
323
  if single? && parents.length > 1
291
324
  raise Errors::SingularSideload.new(self, parents.length)
@@ -454,20 +487,50 @@ module Graphiti
454
487
  Util::Class.namespace_for(klass)
455
488
  end
456
489
 
457
- # TODO: call this at runtime to support procs
490
+ def dynamic_flag?(flag)
491
+ flag.is_a?(Symbol) || flag.is_a?(String) || flag.is_a?(Proc)
492
+ end
493
+
494
+ # The guard method may be defined on either resource. The declaring side
495
+ # wins (`has_many :positions, readable: :admin?` calls EmployeeResource#admin?,
496
+ # matching how attribute guards resolve). If the related resource is the only place
497
+ # that defines it, the guard runs there. For instance, PositionResource#admin? can guard
498
+ # every relationship that points at positions.
499
+
458
500
  def evaluate_flag(flag)
459
501
  return false if flag.blank?
460
502
 
461
- case flag.class.name
462
- when "Symbol", "String"
463
- resource.send(flag)
464
- when "Proc"
465
- resource.instance_exec(&flag)
503
+ case flag
504
+ when Symbol, String
505
+ guard_resource(flag).send(flag)
506
+ when Proc
507
+ evaluate_guard_proc(flag)
466
508
  else
467
509
  !!flag
468
510
  end
469
511
  end
470
512
 
513
+ # Private methods are eligible - guards are often not part of a
514
+ # resource's public API.
515
+ def guard_resource(method_name)
516
+ if !parent_resource.respond_to?(method_name, true) &&
517
+ resource.respond_to?(method_name, true)
518
+ resource
519
+ else
520
+ parent_resource
521
+ end
522
+ end
523
+
524
+ # A proc can't be introspected the way a method name can, so run it
525
+ # against the declaring resource and only reach for the related one if
526
+ # that resource turns out not to define what the proc references.
527
+ def evaluate_guard_proc(flag)
528
+ parent_resource.instance_exec(&flag)
529
+ rescue NameError => error
530
+ raise error unless resource.respond_to?(error.name, true)
531
+ resource.instance_exec(&flag)
532
+ end
533
+
471
534
  def context
472
535
  Graphiti.context[:object]
473
536
  end
@@ -50,14 +50,41 @@ module Graphiti
50
50
  end
51
51
  end
52
52
 
53
+ # Guards may optionally accept the model being written and the attribute
54
+ # name, mirroring readable guards.
53
55
  def guard_passes?
54
- !!resource.send(attribute[flag])
56
+ result = case guard_arity
57
+ when 0 then call_guard
58
+ when 1 then call_guard(resource.guard_model)
59
+ else call_guard(resource.guard_model, name.to_s)
60
+ end
61
+ !!result
62
+ end
63
+
64
+ def call_guard(*args)
65
+ if guard.is_a?(Proc)
66
+ resource.instance_exec(*args, &guard)
67
+ else
68
+ resource.send(guard, *args)
69
+ end
70
+ end
71
+
72
+ def guard_arity
73
+ method = guard.is_a?(Proc) ? guard : resource.method(guard)
74
+ # Negative arity means optional or splatted params - hand over
75
+ # everything and let the guard take what it wants.
76
+ method.arity.negative? ? 2 : method.arity
77
+ end
78
+
79
+ def guard
80
+ attribute[flag]
55
81
  end
56
82
 
57
83
  def guarded?
58
- request? &&
59
- attribute[flag].is_a?(Symbol) &&
60
- attribute[flag] != :required
84
+ return false unless request?
85
+ return false if guard == :required
86
+
87
+ guard.is_a?(Symbol) || guard.is_a?(String) || guard.is_a?(Proc)
61
88
  end
62
89
 
63
90
  def supported?
@@ -19,8 +19,7 @@ module Graphiti
19
19
  private
20
20
 
21
21
  def apply?(sideload)
22
- @serializer.relationship_blocks[sideload.name].nil? &&
23
- sideload.readable?
22
+ @serializer.relationship_blocks[sideload.name].nil?
24
23
  end
25
24
  end
26
25
 
@@ -32,7 +31,8 @@ module Graphiti
32
31
  end
33
32
 
34
33
  def apply
35
- @serializer.relationship(@sideload.name, &block)
34
+ sideload = @sideload
35
+ @serializer.relationship(@sideload.name, if: -> { sideload.readable? }, &block)
36
36
  end
37
37
 
38
38
  # If we can't eagerly validate links on app boot, we do it at runtime
@@ -1,3 +1,3 @@
1
1
  module Graphiti
2
- VERSION = "1.11.1"
2
+ VERSION = "1.12.1"
3
3
  end
data/lib/graphiti.rb CHANGED
@@ -62,6 +62,18 @@ module Graphiti
62
62
  @resources ||= []
63
63
  end
64
64
 
65
+ # Every guarded relationship, e.g. ["EmployeeResource.positions"]
66
+ def self.guarded_relationships
67
+ ::Rails.application.eager_load! if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
68
+
69
+ resources.flat_map { |resource_class|
70
+ resource_class.config[:sideloads]
71
+ .select { |_name, sideload| sideload.guarded? }
72
+ .keys
73
+ .map { |relationship_name| "#{resource_class.name || "(anonymous resource)"}.#{relationship_name}" }
74
+ }.uniq.sort
75
+ end
76
+
65
77
  def self.broadcast(name, payload)
66
78
  # AS::N prefers domain naming format with more specific towards end
67
79
  name = "#{name}.graphiti"
data/package.json CHANGED
@@ -37,43 +37,66 @@
37
37
  {
38
38
  "releaseRules": [
39
39
  {
40
- "type": "*!",
40
+ "breakingMarker": "!",
41
41
  "release": "major"
42
42
  },
43
+ {
44
+ "breaking": true,
45
+ "release": "major"
46
+ },
47
+ {
48
+ "revert": true,
49
+ "release": "patch"
50
+ },
43
51
  {
44
52
  "type": "feat",
45
53
  "release": "minor"
46
54
  },
47
55
  {
48
- "type": "build",
56
+ "type": "fix",
49
57
  "release": "patch"
50
58
  },
51
59
  {
52
- "type": "ci",
60
+ "type": "perf",
53
61
  "release": "patch"
54
62
  },
63
+ {
64
+ "type": "build",
65
+ "release": false
66
+ },
67
+ {
68
+ "type": "ci",
69
+ "release": false
70
+ },
55
71
  {
56
72
  "type": "chore",
57
- "release": "patch"
73
+ "release": false
58
74
  },
59
75
  {
60
76
  "type": "docs",
61
- "release": "patch"
77
+ "release": false
62
78
  },
63
79
  {
64
80
  "type": "refactor",
65
- "release": "patch"
81
+ "release": false
66
82
  },
67
83
  {
68
84
  "type": "style",
69
- "release": "patch"
85
+ "release": false
70
86
  },
71
87
  {
72
88
  "type": "test",
73
- "release": "patch"
89
+ "release": false
74
90
  }
75
91
  ],
76
92
  "parserOpts": {
93
+ "headerPattern": "^(\\w*)(?:\\((.*)\\))?(!)?: (.*)$",
94
+ "headerCorrespondence": [
95
+ "type",
96
+ "scope",
97
+ "breakingMarker",
98
+ "subject"
99
+ ],
77
100
  "noteKeywords": [
78
101
  "BREAKING CHANGE",
79
102
  "BREAKING CHANGES",
@@ -85,7 +108,20 @@
85
108
  }
86
109
  }
87
110
  ],
88
- "@semantic-release/release-notes-generator",
111
+ [
112
+ "@semantic-release/release-notes-generator",
113
+ {
114
+ "parserOpts": {
115
+ "headerPattern": "^(\\w*)(?:\\((.*)\\))?(!)?: (.*)$",
116
+ "headerCorrespondence": [
117
+ "type",
118
+ "scope",
119
+ "breakingMarker",
120
+ "subject"
121
+ ]
122
+ }
123
+ }
124
+ ],
89
125
  [
90
126
  "@semantic-release/changelog",
91
127
  {
@@ -99,7 +135,8 @@
99
135
  "@semantic-release/git",
100
136
  {
101
137
  "assets": [
102
- "CHANGELOG.md"
138
+ "CHANGELOG.md",
139
+ "lib/graphiti/version.rb"
103
140
  ],
104
141
  "message": "${nextRelease.version} CHANGELOG [skip ci]\n\n${nextRelease.notes}"
105
142
  }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphiti
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.11.1
4
+ version: 1.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lee Richmond
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-28 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jsonapi-serializable
@@ -242,6 +242,7 @@ files:
242
242
  - Appraisals
243
243
  - CHANGELOG.md
244
244
  - CODE_OF_CONDUCT.md
245
+ - CONTRIBUTING.md
245
246
  - Gemfile
246
247
  - Guardfile
247
248
  - LICENSE.txt
@@ -370,7 +371,14 @@ homepage: https://github.com/graphiti-api/graphiti
370
371
  licenses:
371
372
  - MIT
372
373
  metadata: {}
373
- post_install_message:
374
+ post_install_message: "Graphiti: relationship readable:/writable: guards are now enforced.\n\nSymbols,
375
+ strings, and procs passed to a relationship's readable/writable\nguards were always
376
+ interpreted as true and never actually called before this\nconditional relationship
377
+ change. They are now evaluated per-request, where\nunreadable relationships are
378
+ omitted from responses and includes, and unwritable\nrelationships reject writes.\n\nTo
379
+ list every affected relationship in your app:\n\n bin/rails runner 'puts Graphiti.guarded_relationships'\n\nAudit
380
+ these before deploying. Apps using schema.json will also see affected \nrelationships
381
+ flagged as \"became guarded\" by the schema check.\n"
374
382
  rdoc_options: []
375
383
  require_paths:
376
384
  - lib