graphiti 1.12.2 → 2.0.0.beta.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 +6 -37
- data/.standard.yml +1 -1
- data/Appraisals +0 -15
- data/CHANGELOG.md +14 -0
- data/UPGRADING.md +71 -0
- data/graphiti.gemspec +1 -1
- data/lib/graphiti/errors.rb +19 -0
- data/lib/graphiti/query.rb +1 -0
- data/lib/graphiti/request_validators/validator.rb +0 -1
- data/lib/graphiti/resource/interface.rb +1 -1
- data/lib/graphiti/resource/persistence.rb +37 -19
- data/lib/graphiti/resource.rb +11 -2
- data/lib/graphiti/resource_proxy.rb +86 -5
- data/lib/graphiti/runner.rb +2 -1
- data/lib/graphiti/serializer.rb +1 -0
- data/lib/graphiti/util/hash.rb +9 -2
- data/lib/graphiti/util/persistence.rb +39 -3
- data/lib/graphiti/version.rb +1 -1
- metadata +6 -7
- data/gemfiles/rails_5_2.gemfile +0 -18
- data/gemfiles/rails_5_2_graphiti_rails.gemfile +0 -19
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a741f7953d1f54e730d2e801715f3b1d718044a4e0bd59cfd8bb3b364373e715
|
|
4
|
+
data.tar.gz: 99391699d407dfb92178cf851cce313d691ea930eb0ada81abe764b7a8310087
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 77d02ef7c1a525c286d56677ef103f56c7ea80873f565c6db72d2208d887fac967c8f9cddc6a06f940c4a62c6d7faf58390f5331f18fd2a562aa855fff002afd
|
|
7
|
+
data.tar.gz: e4e83880da4609da20727c8e8bf7b21cddc6e7436d571218da2f874e682dfdbe03c6960799e12614716b936881a8af95ee0459d6458afcaf5f3818dae198543e
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -2,7 +2,7 @@ name: CI
|
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
|
-
branches: [main]
|
|
5
|
+
branches: [main, beta]
|
|
6
6
|
pull_request: {}
|
|
7
7
|
workflow_dispatch: {}
|
|
8
8
|
|
|
@@ -29,7 +29,6 @@ jobs:
|
|
|
29
29
|
fail-fast: false
|
|
30
30
|
matrix:
|
|
31
31
|
ruby:
|
|
32
|
-
- "2.7"
|
|
33
32
|
- "3.0"
|
|
34
33
|
- "3.1"
|
|
35
34
|
- "3.2"
|
|
@@ -37,10 +36,8 @@ jobs:
|
|
|
37
36
|
- "3.4"
|
|
38
37
|
gemfile:
|
|
39
38
|
- Gemfile
|
|
40
|
-
- gemfiles/rails_5_2.gemfile
|
|
41
39
|
- gemfiles/rails_6.gemfile
|
|
42
40
|
- gemfiles/rails_7.gemfile
|
|
43
|
-
- gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
44
41
|
- gemfiles/rails_6_graphiti_rails.gemfile
|
|
45
42
|
- gemfiles/rails_7_graphiti_rails.gemfile
|
|
46
43
|
- gemfiles/rails_7_1_graphiti_rails.gemfile
|
|
@@ -70,14 +67,10 @@ jobs:
|
|
|
70
67
|
# Skip some extra variants
|
|
71
68
|
- gemfile: Gemfile
|
|
72
69
|
appraisal: true
|
|
73
|
-
- gemfile: gemfiles/rails_5_2.gemfile
|
|
74
|
-
appraisal: false
|
|
75
70
|
- gemfile: gemfiles/rails_6.gemfile
|
|
76
71
|
appraisal: false
|
|
77
72
|
- gemfile: gemfiles/rails_7.gemfile
|
|
78
73
|
appraisal: false
|
|
79
|
-
- gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
80
|
-
appraisal: false
|
|
81
74
|
- gemfile: gemfiles/rails_6_graphiti_rails.gemfile
|
|
82
75
|
appraisal: false
|
|
83
76
|
- gemfile: gemfiles/rails_7_graphiti_rails.gemfile
|
|
@@ -92,30 +85,22 @@ jobs:
|
|
|
92
85
|
appraisal: false
|
|
93
86
|
|
|
94
87
|
# Rails 8.1 needs ruby 3.2 +
|
|
95
|
-
- gemfile: gemfiles/rails_8_1_graphiti_rails.gemfile
|
|
96
|
-
ruby: 2.7
|
|
97
88
|
- gemfile: gemfiles/rails_8_1_graphiti_rails.gemfile
|
|
98
89
|
ruby: 3.0
|
|
99
90
|
- gemfile: gemfiles/rails_8_1_graphiti_rails.gemfile
|
|
100
91
|
ruby: 3.1
|
|
101
92
|
|
|
102
93
|
# Rails 8 needs ruby 3.2 +
|
|
103
|
-
- gemfile: gemfiles/rails_8_0_graphiti_rails.gemfile
|
|
104
|
-
ruby: 2.7
|
|
105
94
|
- gemfile: gemfiles/rails_8_0_graphiti_rails.gemfile
|
|
106
95
|
ruby: 3.0
|
|
107
96
|
- gemfile: gemfiles/rails_8_0_graphiti_rails.gemfile
|
|
108
97
|
ruby: 3.1
|
|
109
98
|
|
|
110
99
|
# Rails 7.2 needs ruby 3.1 +
|
|
111
|
-
- gemfile: gemfiles/rails_7_2_graphiti_rails.gemfile
|
|
112
|
-
ruby: 2.7
|
|
113
100
|
- gemfile: gemfiles/rails_7_2_graphiti_rails.gemfile
|
|
114
101
|
ruby: 3.0
|
|
115
102
|
|
|
116
103
|
# Rails 7.1 needs ruby 3.0 +
|
|
117
|
-
- gemfile: gemfiles/rails_7_1_graphiti_rails.gemfile
|
|
118
|
-
ruby: 2.7
|
|
119
104
|
- gemfile: gemfiles/rails_7_1_graphiti_rails.gemfile
|
|
120
105
|
ruby: 3.0
|
|
121
106
|
|
|
@@ -133,27 +118,6 @@ jobs:
|
|
|
133
118
|
- gemfile: gemfiles/rails_6_graphiti_rails.gemfile
|
|
134
119
|
ruby: 3.4
|
|
135
120
|
|
|
136
|
-
# Rails 5 can't run on Ruby 3
|
|
137
|
-
- gemfile: gemfiles/rails_5_2.gemfile
|
|
138
|
-
ruby: 3.0
|
|
139
|
-
- gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
140
|
-
ruby: 3.0
|
|
141
|
-
- gemfile: gemfiles/rails_5_2.gemfile
|
|
142
|
-
ruby: 3.1
|
|
143
|
-
- gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
144
|
-
ruby: 3.1
|
|
145
|
-
- gemfile: gemfiles/rails_5_2.gemfile
|
|
146
|
-
ruby: 3.2
|
|
147
|
-
- gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
148
|
-
ruby: 3.2
|
|
149
|
-
- gemfile: gemfiles/rails_5_2.gemfile
|
|
150
|
-
ruby: 3.3
|
|
151
|
-
- gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
152
|
-
ruby: 3.3
|
|
153
|
-
- gemfile: gemfiles/rails_5_2.gemfile
|
|
154
|
-
ruby: 3.4
|
|
155
|
-
- gemfile: gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
156
|
-
ruby: 3.4
|
|
157
121
|
continue-on-error: ${{ matrix.ruby == 'ruby-head' }}
|
|
158
122
|
env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
|
|
159
123
|
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
|
|
@@ -171,6 +135,8 @@ jobs:
|
|
|
171
135
|
publish:
|
|
172
136
|
name: Release
|
|
173
137
|
runs-on: ubuntu-latest
|
|
138
|
+
# Only main auto-releases. Beta releases are cut by manually running
|
|
139
|
+
# "Generate New Release" with the beta branch selected.
|
|
174
140
|
if: github.ref == 'refs/heads/main'
|
|
175
141
|
needs: [test]
|
|
176
142
|
steps:
|
|
@@ -178,4 +144,7 @@ jobs:
|
|
|
178
144
|
uses: benc-uk/workflow-dispatch@v1
|
|
179
145
|
with:
|
|
180
146
|
workflow: Generate New Release
|
|
147
|
+
# Without an explicit ref the dispatch runs against the default
|
|
148
|
+
# branch rather than the branch that triggered it.
|
|
149
|
+
ref: ${{ github.ref }}
|
|
181
150
|
token: ${{ secrets.GITHUB_TOKEN }}
|
data/.standard.yml
CHANGED
data/Appraisals
CHANGED
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
appraise "rails-5_2" do
|
|
2
|
-
gem "rails", "~> 5.2.0"
|
|
3
|
-
gem "rspec-rails"
|
|
4
|
-
gem "sqlite3", "~> 1.4.0"
|
|
5
|
-
gem "database_cleaner"
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
appraise "rails-5_2-graphiti-rails" do
|
|
9
|
-
gem "rails", "~> 5.2.0"
|
|
10
|
-
gem "rspec-rails"
|
|
11
|
-
gem "sqlite3", "~> 1.4.0"
|
|
12
|
-
gem "database_cleaner"
|
|
13
|
-
gem "graphiti-rails", "~> 0.4.0"
|
|
14
|
-
end
|
|
15
|
-
|
|
16
1
|
appraise "rails-6" do
|
|
17
2
|
gem "rails", "~> 6.0"
|
|
18
3
|
gem "rspec-rails"
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
graphiti changelog
|
|
2
2
|
|
|
3
|
+
# [2.0.0-beta.1](https://github.com/graphiti-api/graphiti/compare/v1.12.2...v2.0.0-beta.1) (2026-07-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* drop Ruby 2.7 and Rails 5.2 support ([e905ddb](https://github.com/graphiti-api/graphiti/commit/e905ddb5c842299ffbfe5ec3c4ff98ec9b603a51))
|
|
9
|
+
* the model you inspect is the model that saves ([#465](https://github.com/graphiti-api/graphiti/issues/465)) ([a905fff](https://github.com/graphiti-api/graphiti/commit/a905fffc3a2aa5e663ff4cadcd632e68f053c81a))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### BREAKING CHANGES
|
|
13
|
+
|
|
14
|
+
* around_persistence hooks receive the assigned model instead of the attributes hash. Move attribute-hash modifications to before_attributes, or set values on the model. Custom create/update overrides that should receive a pre-assigned model must accept an assigned_model: keyword. See UPGRADING.md
|
|
15
|
+
* Ruby >= 3.0 / Rails >= 6 are now required.
|
|
16
|
+
|
|
3
17
|
## [1.12.2](https://github.com/graphiti-api/graphiti/compare/v1.12.1...v1.12.2) (2026-07-29)
|
|
4
18
|
|
|
5
19
|
|
data/UPGRADING.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Upgrading Graphiti
|
|
2
|
+
|
|
3
|
+
## 1.x to 2.0
|
|
4
|
+
|
|
5
|
+
### Requirements
|
|
6
|
+
|
|
7
|
+
Graphiti 2.0 requires Ruby 3.0+ and Rails 6.0+ (when using Rails).
|
|
8
|
+
|
|
9
|
+
### The model you inspect is the model that saves
|
|
10
|
+
|
|
11
|
+
Proxies returned by `build` and `find` now apply the request payload lazily, and expose the resulting model before anything is written:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Creates
|
|
15
|
+
resource = MyResource.build(params)
|
|
16
|
+
resource.data # unsaved model, attributes applied
|
|
17
|
+
resource.data.valid? # inspect before committing to anything
|
|
18
|
+
resource.save # persists that same instance
|
|
19
|
+
|
|
20
|
+
# Updates
|
|
21
|
+
resource = MyResource.find(params)
|
|
22
|
+
resource.assign_attributes(params)
|
|
23
|
+
resource.data.changed # dirty tracking works
|
|
24
|
+
resource.update
|
|
25
|
+
|
|
26
|
+
# or assign and save in one call, Rails-style
|
|
27
|
+
resource = MyResource.find(params)
|
|
28
|
+
resource.update(params)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`assign_attributes` is idempotent per payload, validation still runs before assignment, and the instance you inspect is the instance that saves.
|
|
32
|
+
|
|
33
|
+
### Breaking: around_persistence receives the model, not the attributes hash
|
|
34
|
+
|
|
35
|
+
To make the above hold, attributes are assigned to the model once, before the persistence hooks fire. `around_persistence` now wraps the save of an already-assigned model and receives that model:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# 1.x
|
|
39
|
+
def do_around_persistence(attributes)
|
|
40
|
+
attributes[:tenant_id] = current_tenant.id
|
|
41
|
+
model = yield
|
|
42
|
+
model.log_saved!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# 2.0
|
|
46
|
+
def do_around_persistence(model)
|
|
47
|
+
model.tenant_id = current_tenant.id # last chance to touch the model before save, inside the transaction
|
|
48
|
+
saved = yield
|
|
49
|
+
saved.log_saved!
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
To migrate, move attribute-hash modifications to `before_attributes` (which still receives the mutable hash, before assignment), or set the value on the model as above. Hooks that only wrap their yield - transactions, timing, post-save side effects - need no changes. Graphiti 1.x releases warn at runtime when a hook would be affected.
|
|
54
|
+
|
|
55
|
+
`before/around/after_attributes` and `before/around/after_save` are unchanged.
|
|
56
|
+
|
|
57
|
+
### Breaking: custom create/update overrides and pre-assigned models
|
|
58
|
+
|
|
59
|
+
If a resource overrides `create` or `update` and callers inspect the model before saving, the override must accept the already-assigned model:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
def create(attributes, meta = nil, assigned_model: nil)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Without the keyword, saving a proxy whose model was inspected raises `Graphiti::Errors::AssignedModelNotSupported` rather than silently discarding changes made to the inspected instance. Overridden resources used only via plain `save` are unaffected.
|
|
66
|
+
|
|
67
|
+
### Fine print
|
|
68
|
+
|
|
69
|
+
- If you inspect the model before saving, the attributes callbacks run at inspection time (in your controller, outside the save transaction). On the plain `save` path they run inside the transaction, at the same point as 1.x.
|
|
70
|
+
- Writable guards judge persisted state: a guard asking for the model gets a fresh build/find, never the current request's unsaved changes. A payload cannot influence its own authorization.
|
|
71
|
+
- Sideposted child models are still built and assigned during save; `data` exposes the pre-assigned root model only.
|
data/graphiti.gemspec
CHANGED
|
@@ -16,7 +16,7 @@ Gem::Specification.new do |spec|
|
|
|
16
16
|
spec.bindir = "exe"
|
|
17
17
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
18
18
|
spec.require_paths = ["lib"]
|
|
19
|
-
spec.required_ruby_version = ">=
|
|
19
|
+
spec.required_ruby_version = ">= 3.0"
|
|
20
20
|
|
|
21
21
|
# TODO: remove once the 1.12 upgrade window has passed
|
|
22
22
|
spec.post_install_message = <<~MSG
|
data/lib/graphiti/errors.rb
CHANGED
|
@@ -84,6 +84,25 @@ module Graphiti
|
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
class AssignedModelNotSupported < Base
|
|
88
|
+
def initialize(resource_class, method_name)
|
|
89
|
+
@resource_class = resource_class
|
|
90
|
+
@method_name = method_name
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def message
|
|
94
|
+
<<~MSG
|
|
95
|
+
#{@resource_class}: the model was already assigned via #assign_attributes (or #data before save), but ##{@method_name} is overridden without a parameter to receive it, so changes made to the unsaved model would be silently lost.
|
|
96
|
+
|
|
97
|
+
Accept and use the assigned model in your override:
|
|
98
|
+
|
|
99
|
+
def #{@method_name}(attributes, meta = nil, assigned_model: nil)
|
|
100
|
+
|
|
101
|
+
or persist with #save alone, without inspecting the model first. See UPGRADING.md.
|
|
102
|
+
MSG
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
87
106
|
class RemoteWrite < Base
|
|
88
107
|
def initialize(resource_class)
|
|
89
108
|
@resource_class = resource_class
|
data/lib/graphiti/query.rb
CHANGED
|
@@ -4,6 +4,7 @@ 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
|
|
7
8
|
def initialize(resource, params, association_name = nil, nested_include = nil, parents = [], action = nil)
|
|
8
9
|
@resource = resource
|
|
9
10
|
@association_name = association_name
|
|
@@ -17,7 +17,6 @@ module Graphiti
|
|
|
17
17
|
return true unless @params.has_key?(:data)
|
|
18
18
|
|
|
19
19
|
resource = @root_resource
|
|
20
|
-
|
|
21
20
|
if @params[:data].has_key?(:type)
|
|
22
21
|
if (meta_type = deserialized_payload.meta[:type].try(:to_sym))
|
|
23
22
|
if @root_resource.type != meta_type && @root_resource.polymorphic?
|
|
@@ -50,7 +50,7 @@ module Graphiti
|
|
|
50
50
|
def build(params, base_scope = nil)
|
|
51
51
|
validate_request!(params)
|
|
52
52
|
runner = Runner.new(self, params)
|
|
53
|
-
runner.proxy(base_scope, single: true, raise_on_missing: true)
|
|
53
|
+
runner.proxy(base_scope, single: true, raise_on_missing: true, assign_action: :create)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
private
|
|
@@ -69,39 +69,57 @@ module Graphiti
|
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
# +model_instance+ is the already-built model from a previous #assign
|
|
73
|
+
# (see ResourceProxy#assign_attributes). When given, attributes are
|
|
74
|
+
# applied to it rather than to a freshly built/found model.
|
|
75
|
+
def assign(assign_params, meta = nil, action_name = nil, model_instance: nil)
|
|
76
|
+
# Only update strips :id (it identifies the record to find) - a create
|
|
77
|
+
# payload may legitimately carry a client-supplied id to assign.
|
|
78
|
+
if action_name == :update
|
|
79
|
+
id = assign_params[:id]
|
|
80
|
+
assign_params = assign_params.except(:id)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
run_callbacks :attributes, action_name, assign_params, meta do |params|
|
|
84
|
+
model_instance ||= if action_name == :update
|
|
85
|
+
self.class._find(id: id).data
|
|
86
|
+
else
|
|
87
|
+
call_with_meta(:build, model, meta)
|
|
80
88
|
end
|
|
89
|
+
call_with_meta(:assign_attributes, model_instance, params, meta)
|
|
90
|
+
model_instance
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
model_instance
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Attributes are assigned before the persistence callbacks fire, so
|
|
97
|
+
# around_persistence receives the assigned model - its pre-yield
|
|
98
|
+
# position is the last chance to touch the model before save, inside
|
|
99
|
+
# the transaction. Modify attributes in before_attributes instead.
|
|
100
|
+
def create(create_params, meta = nil, assigned_model: nil)
|
|
101
|
+
model_instance = assigned_model || assign(create_params, meta, :create)
|
|
81
102
|
|
|
103
|
+
run_callbacks :persistence, :create, model_instance, meta do
|
|
82
104
|
run_callbacks :save, :create, model_instance, meta do
|
|
83
105
|
model_instance = call_with_meta(:save, model_instance, meta)
|
|
84
106
|
end
|
|
85
107
|
|
|
86
108
|
model_instance
|
|
87
109
|
end
|
|
88
|
-
end
|
|
89
110
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
id = update_params[:id]
|
|
93
|
-
update_params = update_params.except(:id)
|
|
111
|
+
model_instance
|
|
112
|
+
end
|
|
94
113
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
model_instance = self.class._find(id: id).data
|
|
98
|
-
call_with_meta(:assign_attributes, model_instance, params, meta)
|
|
99
|
-
model_instance
|
|
100
|
-
end
|
|
114
|
+
def update(update_params, meta = nil, assigned_model: nil)
|
|
115
|
+
model_instance = assigned_model || assign(update_params, meta, :update)
|
|
101
116
|
|
|
117
|
+
run_callbacks :persistence, :update, model_instance, meta do
|
|
102
118
|
run_callbacks :save, :update, model_instance, meta do
|
|
103
119
|
model_instance = call_with_meta(:save, model_instance, meta)
|
|
104
120
|
end
|
|
121
|
+
|
|
122
|
+
model_instance
|
|
105
123
|
end
|
|
106
124
|
|
|
107
125
|
model_instance
|
data/lib/graphiti/resource.rb
CHANGED
|
@@ -123,9 +123,18 @@ module Graphiti
|
|
|
123
123
|
adapter.disassociate(parent, child, association_name, type)
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
# TODO: make foreign_key a keyword once the satellite gems are rolled in - they call these positionally
|
|
127
|
+
def assign_with_relationships(meta, attributes, relationships, caller_model = nil, foreign_key = nil, model_instance: nil)
|
|
127
128
|
persistence = Graphiti::Util::Persistence \
|
|
128
|
-
.new(self, meta, attributes, relationships, caller_model, foreign_key
|
|
129
|
+
.new(self, meta, attributes, relationships, caller_model, foreign_key,
|
|
130
|
+
assigned_model: model_instance)
|
|
131
|
+
persistence.assign
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def persist_with_relationships(meta, attributes, relationships, caller_model = nil, foreign_key = nil, assigned_model: nil)
|
|
135
|
+
persistence = Graphiti::Util::Persistence \
|
|
136
|
+
.new(self, meta, attributes, relationships, caller_model, foreign_key,
|
|
137
|
+
assigned_model: assigned_model)
|
|
129
138
|
persistence.run
|
|
130
139
|
end
|
|
131
140
|
|
|
@@ -11,6 +11,7 @@ module Graphiti
|
|
|
11
11
|
payload: nil,
|
|
12
12
|
single: false,
|
|
13
13
|
raise_on_missing: false,
|
|
14
|
+
assign_action: nil,
|
|
14
15
|
cache: nil,
|
|
15
16
|
cache_expires_in: nil,
|
|
16
17
|
cache_tag: nil
|
|
@@ -22,6 +23,7 @@ module Graphiti
|
|
|
22
23
|
@payload = payload
|
|
23
24
|
@single = single
|
|
24
25
|
@raise_on_missing = raise_on_missing
|
|
26
|
+
@assign_action = assign_action
|
|
25
27
|
@cache = cache
|
|
26
28
|
@cache_expires_in = cache_expires_in
|
|
27
29
|
@cache_tag = cache_tag
|
|
@@ -81,7 +83,10 @@ module Graphiti
|
|
|
81
83
|
end
|
|
82
84
|
|
|
83
85
|
def data
|
|
84
|
-
@data
|
|
86
|
+
return @data unless @data.nil?
|
|
87
|
+
return assign_attributes(@payload.params) if @assign_action
|
|
88
|
+
|
|
89
|
+
@data = begin
|
|
85
90
|
records = @scope.resolve
|
|
86
91
|
raise Graphiti::Errors::RecordNotFound if records.empty? && raise_on_missing?
|
|
87
92
|
|
|
@@ -89,6 +94,7 @@ module Graphiti
|
|
|
89
94
|
records
|
|
90
95
|
end
|
|
91
96
|
end
|
|
97
|
+
|
|
92
98
|
alias_method :to_a, :data
|
|
93
99
|
alias_method :resolve_data, :data
|
|
94
100
|
|
|
@@ -131,18 +137,56 @@ module Graphiti
|
|
|
131
137
|
@pagination ||= Delegates::Pagination.new(self)
|
|
132
138
|
end
|
|
133
139
|
|
|
140
|
+
# Apply request params to the underlying model without saving it,
|
|
141
|
+
# Rails-style: the params are always passed explicitly, in the same
|
|
142
|
+
# request-params shape find/build accept. They are validated,
|
|
143
|
+
# deserialized, and become the payload #save will persist.
|
|
144
|
+
#
|
|
145
|
+
# Idempotent per params - calling again with params that normalize to
|
|
146
|
+
# the same payload is a no-op, so the attributes callbacks fire once.
|
|
147
|
+
# Different params re-assign onto the same model instance.
|
|
148
|
+
#
|
|
149
|
+
# Note the attributes callbacks fire here, outside any transaction
|
|
150
|
+
# opened during #save - the persistence hooks wrap only the save phase,
|
|
151
|
+
# receiving this assigned model.
|
|
152
|
+
def assign_attributes(params)
|
|
153
|
+
action = @assign_action || :update
|
|
154
|
+
params = normalized_params_copy(params)
|
|
155
|
+
add_endpoint_filter(params, action)
|
|
156
|
+
validator = ::Graphiti::RequestValidator.new(@resource, params, action)
|
|
157
|
+
validator.validate!
|
|
158
|
+
|
|
159
|
+
if @assigned_model && same_write_payload?(validator.deserialized_payload)
|
|
160
|
+
return @assigned_model
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
@payload = validator.deserialized_payload
|
|
164
|
+
@assigned_model = @data = @resource.assign_with_relationships(
|
|
165
|
+
@payload.meta(action: action),
|
|
166
|
+
@payload.attributes,
|
|
167
|
+
@payload.relationships,
|
|
168
|
+
model_instance: @assigned_model || (data if action == :update)
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
134
172
|
def save(action: :create)
|
|
135
173
|
# TODO: remove this. Only used for persisting many-to-many with AR
|
|
136
174
|
# (see activerecord adapter)
|
|
137
175
|
original = Graphiti.context[:namespace]
|
|
138
176
|
begin
|
|
139
177
|
Graphiti.context[:namespace] = action
|
|
140
|
-
|
|
178
|
+
# An assigned model can only come from #assign_attributes, which
|
|
179
|
+
# validated the payload it stored - re-validating here would run the
|
|
180
|
+
# writable guards (and their guard_model lookups) a redundant time.
|
|
181
|
+
unless @assigned_model
|
|
182
|
+
::Graphiti::RequestValidator.new(@resource, @payload.params, action).validate!
|
|
183
|
+
end
|
|
141
184
|
validator = persist {
|
|
142
185
|
@resource.persist_with_relationships \
|
|
143
186
|
@payload.meta(action: action),
|
|
144
187
|
@payload.attributes,
|
|
145
|
-
@payload.relationships
|
|
188
|
+
@payload.relationships,
|
|
189
|
+
assigned_model: @assigned_model
|
|
146
190
|
}
|
|
147
191
|
ensure
|
|
148
192
|
Graphiti.context[:namespace] = original
|
|
@@ -179,12 +223,16 @@ module Graphiti
|
|
|
179
223
|
success
|
|
180
224
|
end
|
|
181
225
|
|
|
182
|
-
|
|
226
|
+
# Rails-style: pass params to assign and save in one call, or call with
|
|
227
|
+
# no arguments to save a payload assigned earlier (via find or
|
|
228
|
+
# #assign_attributes).
|
|
229
|
+
def update(params = nil)
|
|
230
|
+
assign_attributes(params) if params
|
|
183
231
|
resolve_data
|
|
184
232
|
save(action: :update)
|
|
185
233
|
end
|
|
186
234
|
|
|
187
|
-
|
|
235
|
+
alias_method :update_attributes, :update
|
|
188
236
|
|
|
189
237
|
def include_hash
|
|
190
238
|
@include_hash ||= begin
|
|
@@ -241,6 +289,39 @@ module Graphiti
|
|
|
241
289
|
|
|
242
290
|
private
|
|
243
291
|
|
|
292
|
+
# Validation typecasts values and injects ids into the params it is
|
|
293
|
+
# given - work on a deep copy so the caller's hash stays untouched.
|
|
294
|
+
def normalized_params_copy(params)
|
|
295
|
+
if params.respond_to?(:to_unsafe_h)
|
|
296
|
+
params.to_unsafe_h.deep_symbolize_keys
|
|
297
|
+
else
|
|
298
|
+
::Graphiti::Util::Hash.deep_dup(params)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# UpdateValidator enforces that data.id matches the endpoint's filter id.
|
|
303
|
+
# Params that came through find already carry that filter; params passed
|
|
304
|
+
# directly to #assign_attributes usually don't, so merge in the id this
|
|
305
|
+
# proxy was found with. A payload whose data.id names a different record
|
|
306
|
+
# still fails validation with ConflictRequest. Mutates the copy made by
|
|
307
|
+
# #normalized_params_copy, never caller state.
|
|
308
|
+
def add_endpoint_filter(params, action)
|
|
309
|
+
return unless action == :update
|
|
310
|
+
|
|
311
|
+
endpoint_id = @query.filters[:id]
|
|
312
|
+
return if endpoint_id.nil? || params[:filter].try(:[], :id)
|
|
313
|
+
|
|
314
|
+
params[:filter] ||= {}
|
|
315
|
+
params[:filter][:id] = endpoint_id
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Compare only the write payload (data + included) - a repeat call whose
|
|
319
|
+
# params differ in read-side keys like sort or page is still a no-op.
|
|
320
|
+
def same_write_payload?(deserialized_payload)
|
|
321
|
+
deserialized_payload.params.values_at(:data, :included) ==
|
|
322
|
+
@payload.params.values_at(:data, :included)
|
|
323
|
+
end
|
|
324
|
+
|
|
244
325
|
def persist
|
|
245
326
|
transaction_response = @resource.transaction do
|
|
246
327
|
::Graphiti::Util::TransactionHooksRecorder.record do
|
data/lib/graphiti/runner.rb
CHANGED
|
@@ -3,12 +3,12 @@ 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
|
|
6
7
|
def initialize(resource_class, params, query = nil, action = nil)
|
|
7
8
|
@resource_class = resource_class
|
|
8
9
|
@params = params
|
|
9
10
|
@query = query
|
|
10
11
|
@action = action
|
|
11
|
-
|
|
12
12
|
validator = RequestValidator.new(jsonapi_resource, params, action)
|
|
13
13
|
validator.validate!
|
|
14
14
|
|
|
@@ -77,6 +77,7 @@ module Graphiti
|
|
|
77
77
|
payload: deserialized_payload,
|
|
78
78
|
single: opts[:single],
|
|
79
79
|
raise_on_missing: opts[:raise_on_missing],
|
|
80
|
+
assign_action: opts[:assign_action],
|
|
80
81
|
cache: opts[:cache],
|
|
81
82
|
cache_expires_in: opts[:cache_expires_in],
|
|
82
83
|
cache_tag: opts[:cache_tag]
|
data/lib/graphiti/serializer.rb
CHANGED
data/lib/graphiti/util/hash.rb
CHANGED
|
@@ -55,8 +55,15 @@ module Graphiti
|
|
|
55
55
|
else
|
|
56
56
|
{}.tap do |duped|
|
|
57
57
|
hash.each_pair do |key, value|
|
|
58
|
-
value =
|
|
59
|
-
|
|
58
|
+
value = if value.is_a?(Hash)
|
|
59
|
+
deep_dup(value)
|
|
60
|
+
elsif value.is_a?(Array)
|
|
61
|
+
value.map { |element| element.is_a?(Hash) ? deep_dup(element) : element }
|
|
62
|
+
elsif value&.respond_to?(:dup) && ![Symbol, Integer].include?(value.class)
|
|
63
|
+
value.dup
|
|
64
|
+
else
|
|
65
|
+
value
|
|
66
|
+
end
|
|
60
67
|
duped[key] = value
|
|
61
68
|
end
|
|
62
69
|
end
|
|
@@ -7,8 +7,12 @@ class Graphiti::Util::Persistence
|
|
|
7
7
|
# @param [Hash] relationships see (Deserializer#relationships)
|
|
8
8
|
# @param [Model] caller_model The persisted parent object in the request graph
|
|
9
9
|
# @param [Symbol] foreign_key Attribute assigned by parent object in graph
|
|
10
|
-
|
|
10
|
+
# @param [Model] assigned_model a model already built by #assign, to be
|
|
11
|
+
# assigned onto rather than rebuilt
|
|
12
|
+
# TODO: make foreign_key a keyword once the satellite gems are rolled in
|
|
13
|
+
def initialize(resource, meta, attributes, relationships, caller_model, foreign_key = nil, assigned_model: nil)
|
|
11
14
|
@resource = resource
|
|
15
|
+
@assigned_model = assigned_model
|
|
12
16
|
@meta = meta
|
|
13
17
|
@attributes = attributes
|
|
14
18
|
@relationships = relationships
|
|
@@ -24,6 +28,14 @@ class Graphiti::Util::Persistence
|
|
|
24
28
|
end
|
|
25
29
|
end
|
|
26
30
|
|
|
31
|
+
def assign
|
|
32
|
+
attributes = @adapter.persistence_attributes(self, @attributes)
|
|
33
|
+
assigned = @resource.assign(attributes, metadata, @meta[:method], model_instance: @assigned_model)
|
|
34
|
+
@resource.decorate_record(assigned)
|
|
35
|
+
|
|
36
|
+
assigned
|
|
37
|
+
end
|
|
38
|
+
|
|
27
39
|
# Perform the actual save logic.
|
|
28
40
|
#
|
|
29
41
|
# belongs_to must be processed before/separately from has_many -
|
|
@@ -46,11 +58,13 @@ class Graphiti::Util::Persistence
|
|
|
46
58
|
def run
|
|
47
59
|
attributes = @adapter.persistence_attributes(self, @attributes)
|
|
48
60
|
|
|
61
|
+
payload_attributes = attributes.dup
|
|
49
62
|
parents = @adapter.process_belongs_to(self, attributes)
|
|
63
|
+
apply_derived_attributes(attributes, payload_attributes)
|
|
50
64
|
persisted = persist_object(@meta[:method], attributes)
|
|
51
65
|
@resource.decorate_record(persisted)
|
|
52
|
-
assign_temp_id(persisted, @meta[:temp_id])
|
|
53
66
|
|
|
67
|
+
assign_temp_id(persisted, @meta[:temp_id])
|
|
54
68
|
associate_parents(persisted, parents)
|
|
55
69
|
|
|
56
70
|
children = @adapter.process_has_many(self, persisted)
|
|
@@ -81,6 +95,19 @@ class Graphiti::Util::Persistence
|
|
|
81
95
|
|
|
82
96
|
private
|
|
83
97
|
|
|
98
|
+
# process_belongs_to persists the parents and writes their primary keys into
|
|
99
|
+
# +attributes+. A model that was already assigned (see ResourceProxy#assign_attributes)
|
|
100
|
+
# predates those keys, so apply just them - the payload attributes are already on it,
|
|
101
|
+
# and re-applying them would clobber any changes made since.
|
|
102
|
+
def apply_derived_attributes(attributes, payload_attributes)
|
|
103
|
+
return unless @assigned_model
|
|
104
|
+
|
|
105
|
+
derived = attributes.reject { |key, value| payload_attributes[key] == value }
|
|
106
|
+
return if derived.empty?
|
|
107
|
+
|
|
108
|
+
@resource.assign_attributes(@assigned_model, derived, metadata)
|
|
109
|
+
end
|
|
110
|
+
|
|
84
111
|
def add_hook(prc, lifecycle_event)
|
|
85
112
|
::Graphiti::Util::TransactionHooksRecorder.add(prc, lifecycle_event)
|
|
86
113
|
end
|
|
@@ -163,10 +190,19 @@ class Graphiti::Util::Persistence
|
|
|
163
190
|
}
|
|
164
191
|
end
|
|
165
192
|
|
|
193
|
+
def accepts_assigned_model?(method)
|
|
194
|
+
method.parameters.any? { |type, name| name == :assigned_model && [:key, :keyreq].include?(type) }
|
|
195
|
+
end
|
|
196
|
+
|
|
166
197
|
def call_resource_method(method_name, attributes, caller_model)
|
|
167
198
|
method = @resource.method(method_name)
|
|
168
199
|
|
|
169
|
-
if
|
|
200
|
+
if @assigned_model && [:create, :update].include?(method_name)
|
|
201
|
+
unless accepts_assigned_model?(method)
|
|
202
|
+
raise Graphiti::Errors::AssignedModelNotSupported.new(@resource.class, method_name)
|
|
203
|
+
end
|
|
204
|
+
method.call(attributes, metadata, assigned_model: @assigned_model)
|
|
205
|
+
elsif method.arity == 1
|
|
170
206
|
method.call(attributes)
|
|
171
207
|
else
|
|
172
208
|
method.call(attributes, metadata)
|
data/lib/graphiti/version.rb
CHANGED
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:
|
|
4
|
+
version: 2.0.0.beta.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-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: jsonapi-serializable
|
|
@@ -248,6 +248,7 @@ files:
|
|
|
248
248
|
- LICENSE.txt
|
|
249
249
|
- README.md
|
|
250
250
|
- Rakefile
|
|
251
|
+
- UPGRADING.md
|
|
251
252
|
- bin/appraisal
|
|
252
253
|
- bin/console
|
|
253
254
|
- bin/rspec
|
|
@@ -269,8 +270,6 @@ files:
|
|
|
269
270
|
- deprecated_generators/graphiti/templates/update_request_spec.rb.erb
|
|
270
271
|
- exe/graphiti
|
|
271
272
|
- gemfiles/.bundle/config
|
|
272
|
-
- gemfiles/rails_5_2.gemfile
|
|
273
|
-
- gemfiles/rails_5_2_graphiti_rails.gemfile
|
|
274
273
|
- gemfiles/rails_6.gemfile
|
|
275
274
|
- gemfiles/rails_6_graphiti_rails.gemfile
|
|
276
275
|
- gemfiles/rails_7.gemfile
|
|
@@ -386,12 +385,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
386
385
|
requirements:
|
|
387
386
|
- - ">="
|
|
388
387
|
- !ruby/object:Gem::Version
|
|
389
|
-
version: '
|
|
388
|
+
version: '3.0'
|
|
390
389
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
391
390
|
requirements:
|
|
392
|
-
- - "
|
|
391
|
+
- - ">"
|
|
393
392
|
- !ruby/object:Gem::Version
|
|
394
|
-
version:
|
|
393
|
+
version: 1.3.1
|
|
395
394
|
requirements: []
|
|
396
395
|
rubygems_version: 3.3.27
|
|
397
396
|
signing_key:
|
data/gemfiles/rails_5_2.gemfile
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
# This file was generated by Appraisal
|
|
2
|
-
|
|
3
|
-
source "https://rubygems.org"
|
|
4
|
-
|
|
5
|
-
gem "rails", "~> 5.2.0"
|
|
6
|
-
gem "rspec-rails"
|
|
7
|
-
gem "sqlite3", "~> 1.4.0"
|
|
8
|
-
gem "database_cleaner"
|
|
9
|
-
|
|
10
|
-
group :test do
|
|
11
|
-
gem "pry"
|
|
12
|
-
gem "pry-byebug", platform: [:mri]
|
|
13
|
-
gem "appraisal"
|
|
14
|
-
gem "guard"
|
|
15
|
-
gem "guard-rspec"
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
gemspec path: "../"
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# This file was generated by Appraisal
|
|
2
|
-
|
|
3
|
-
source "https://rubygems.org"
|
|
4
|
-
|
|
5
|
-
gem "rails", "~> 5.2.0"
|
|
6
|
-
gem "rspec-rails"
|
|
7
|
-
gem "sqlite3", "~> 1.4.0"
|
|
8
|
-
gem "database_cleaner"
|
|
9
|
-
gem "graphiti-rails", "~> 0.4.0"
|
|
10
|
-
|
|
11
|
-
group :test do
|
|
12
|
-
gem "pry"
|
|
13
|
-
gem "pry-byebug", platform: [:mri]
|
|
14
|
-
gem "appraisal"
|
|
15
|
-
gem "guard"
|
|
16
|
-
gem "guard-rspec"
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
gemspec path: "../"
|