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 +4 -4
- data/.github/workflows/ci.yml +1 -0
- data/CHANGELOG.md +20 -0
- data/CONTRIBUTING.md +77 -0
- data/graphiti.gemspec +18 -0
- data/lib/graphiti/hash_renderer.rb +4 -4
- data/lib/graphiti/query.rb +6 -3
- data/lib/graphiti/request_validators/validator.rb +16 -14
- data/lib/graphiti/resource.rb +26 -0
- data/lib/graphiti/schema.rb +4 -0
- data/lib/graphiti/schema_diff.rb +4 -0
- data/lib/graphiti/scope.rb +47 -1
- data/lib/graphiti/serializer.rb +3 -0
- data/lib/graphiti/sideload/polymorphic_belongs_to.rb +14 -1
- data/lib/graphiti/sideload.rb +71 -8
- data/lib/graphiti/util/attribute_check.rb +31 -4
- data/lib/graphiti/util/serializer_relationships.rb +3 -3
- data/lib/graphiti/version.rb +1 -1
- data/lib/graphiti.rb +12 -0
- data/package.json +47 -10
- metadata +11 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96af714433c7faece37e74ab67dad245d5e03396ec391be80cec77622d231faf
|
|
4
|
+
data.tar.gz: 34ce7b027042cd3cda4cf656ba8d96a05908dc0edbe9104ee4fb3597c5970971
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3d0c414812bbccef7567e2e8e48e6b6353582d7801aa71a893f368eb12c71906e1420a1f40dd6d18c8ad6e5d43e3585983340d409742303112dd2737810a26d1
|
|
7
|
+
data.tar.gz: 59774b04ced36164343ddb12a7f2aa2026d2981b0c9df1b6bb92f127abb856467403e64cf72ec15bfde012511f1fa5908bf38bbbecea1becc2527d61df346d72
|
data/.github/workflows/ci.yml
CHANGED
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
|
-
|
|
74
|
-
|
|
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:
|
|
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:
|
|
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
|
data/lib/graphiti/query.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
data/lib/graphiti/resource.rb
CHANGED
|
@@ -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]
|
data/lib/graphiti/schema.rb
CHANGED
data/lib/graphiti/schema_diff.rb
CHANGED
|
@@ -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
|
data/lib/graphiti/scope.rb
CHANGED
|
@@ -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
|
|
data/lib/graphiti/serializer.rb
CHANGED
|
@@ -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)
|
data/lib/graphiti/sideload.rb
CHANGED
|
@@ -99,11 +99,15 @@ module Graphiti
|
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
def readable?
|
|
102
|
-
|
|
102
|
+
evaluate_flag @readable
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
def writable?
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
462
|
-
when
|
|
463
|
-
|
|
464
|
-
when
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
@
|
|
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
|
data/lib/graphiti/version.rb
CHANGED
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
|
-
"
|
|
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": "
|
|
56
|
+
"type": "fix",
|
|
49
57
|
"release": "patch"
|
|
50
58
|
},
|
|
51
59
|
{
|
|
52
|
-
"type": "
|
|
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":
|
|
73
|
+
"release": false
|
|
58
74
|
},
|
|
59
75
|
{
|
|
60
76
|
"type": "docs",
|
|
61
|
-
"release":
|
|
77
|
+
"release": false
|
|
62
78
|
},
|
|
63
79
|
{
|
|
64
80
|
"type": "refactor",
|
|
65
|
-
"release":
|
|
81
|
+
"release": false
|
|
66
82
|
},
|
|
67
83
|
{
|
|
68
84
|
"type": "style",
|
|
69
|
-
"release":
|
|
85
|
+
"release": false
|
|
70
86
|
},
|
|
71
87
|
{
|
|
72
88
|
"type": "test",
|
|
73
|
-
"release":
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|