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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de4409a60f63682b5bb513238e1101ca1997943a18b136c3d9c507f1eeda9651
4
- data.tar.gz: 62b1019afe5fbb095b1323f17c06f7c97f7c15aa0b9dfff3afded57b428ff326
3
+ metadata.gz: a741f7953d1f54e730d2e801715f3b1d718044a4e0bd59cfd8bb3b364373e715
4
+ data.tar.gz: 99391699d407dfb92178cf851cce313d691ea930eb0ada81abe764b7a8310087
5
5
  SHA512:
6
- metadata.gz: b4da6af791178d1a3229dd0c9515792cecacd2bc60ee96274dfc8723af232531a6190e04697f5a1f6e2f9f2b6562a950cfe916657198969e33b0a5251b0854ab
7
- data.tar.gz: 1f6561906d4b70bc148563be8c9d20b2dc028006a238993449d59a51fc125efd7c5110d9564a983ed09d749fcd53f46740667aab737e1e7e13d47977569b4246
6
+ metadata.gz: 77d02ef7c1a525c286d56677ef103f56c7ea80873f565c6db72d2208d887fac967c8f9cddc6a06f940c4a62c6d7faf58390f5331f18fd2a562aa855fff002afd
7
+ data.tar.gz: e4e83880da4609da20727c8e8bf7b21cddc6e7436d571218da2f874e682dfdbe03c6960799e12614716b936881a8af95ee0459d6458afcaf5f3818dae198543e
@@ -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
@@ -1,6 +1,6 @@
1
1
  fix: true
2
2
  parallel: true
3
- ruby_version: 2.7
3
+ ruby_version: 3.0
4
4
 
5
5
  ignore:
6
6
  - 'vendor/**/*'
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 = ">= 2.7"
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
@@ -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
@@ -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
- def create(create_params, meta = nil)
73
- model_instance = nil
74
-
75
- run_callbacks :persistence, :create, create_params, meta do
76
- run_callbacks :attributes, :create, create_params, meta do |params|
77
- model_instance = call_with_meta(:build, model, meta)
78
- call_with_meta(:assign_attributes, model_instance, params, meta)
79
- model_instance
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
- def update(update_params, meta = nil)
91
- model_instance = nil
92
- id = update_params[:id]
93
- update_params = update_params.except(:id)
111
+ model_instance
112
+ end
94
113
 
95
- run_callbacks :persistence, :update, update_params, meta do
96
- run_callbacks :attributes, :update, update_params, meta do |params|
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
@@ -123,9 +123,18 @@ module Graphiti
123
123
  adapter.disassociate(parent, child, association_name, type)
124
124
  end
125
125
 
126
- def persist_with_relationships(meta, attributes, relationships, caller_model = nil, foreign_key = nil)
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 ||= begin
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
- ::Graphiti::RequestValidator.new(@resource, @payload.params, action).validate!
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
- def update
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
- alias update_attributes update # standard:disable Style/Alias
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
@@ -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]
@@ -103,6 +103,7 @@ module Graphiti
103
103
  def strip_relationships?
104
104
  return false unless Graphiti.config.links_on_demand
105
105
  params = Graphiti.context[:object]&.params || {}
106
+
106
107
  [false, nil, "false"].include?(params[:links])
107
108
  end
108
109
  end
@@ -55,8 +55,15 @@ module Graphiti
55
55
  else
56
56
  {}.tap do |duped|
57
57
  hash.each_pair do |key, value|
58
- value = deep_dup(value) if value.is_a?(Hash)
59
- value = value.dup if value&.respond_to?(:dup) && ![Symbol, Integer].include?(value.class)
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
- def initialize(resource, meta, attributes, relationships, caller_model, foreign_key = nil)
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 method.arity == 1
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)
@@ -1,3 +1,3 @@
1
1
  module Graphiti
2
- VERSION = "1.12.2"
2
+ VERSION = "2.0.0.beta.1"
3
3
  end
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.12.2
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-29 00:00:00.000000000 Z
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: '2.7'
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: '0'
393
+ version: 1.3.1
395
394
  requirements: []
396
395
  rubygems_version: 3.3.27
397
396
  signing_key:
@@ -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: "../"