graphiti 1.13.0 → 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: 5b0c2234b78e4616fea8ab95f8b6f044d18aa4feafb103cb4eb7d0fed35f8dd6
4
- data.tar.gz: 2b276253a3b5b802dbf143ccd6fdc7d17287ee542c60f352ab939a33f16da233
3
+ metadata.gz: a741f7953d1f54e730d2e801715f3b1d718044a4e0bd59cfd8bb3b364373e715
4
+ data.tar.gz: 99391699d407dfb92178cf851cce313d691ea930eb0ada81abe764b7a8310087
5
5
  SHA512:
6
- metadata.gz: 01ac700f8481142c1842488db436df2bedc508acabf3bde7fcd4d9e0e1a9882ab0df8c23ac5473b68a8ab1717b6041a34fde6b18125d269ccad88f23d8a3f3aa
7
- data.tar.gz: be85a9c17b7b1df85c5ff755b4aebfeb6bd814e4a88a87b926f05056d776f5b77ec643ab1c8800e155cf541149c30d9c343471ef9427b0dcc69ae6c2e79b3295
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,12 +1,18 @@
1
1
  graphiti changelog
2
2
 
3
- # [1.13.0](https://github.com/graphiti-api/graphiti/compare/v1.12.2...v1.13.0) (2026-07-30)
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
4
 
5
5
 
6
6
  ### Features
7
7
 
8
- * add Resource.wrap as an easy way to use graphiti serialization on models fetched via other means (and not via graphiti's finders) ([#513](https://github.com/graphiti-api/graphiti/issues/513)) ([fcd19e2](https://github.com/graphiti-api/graphiti/commit/fcd19e2d023091d85947dbdd89259b587e0f582b))
9
- * deprecate mutating attributes in around_persistence hooks ([#514](https://github.com/graphiti-api/graphiti/issues/514)) [skip ci] ([8410ab0](https://github.com/graphiti-api/graphiti/commit/8410ab0a916077601cf85d8260a05ef9098149eb))
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.
10
16
 
11
17
  ## [1.12.2](https://github.com/graphiti-api/graphiti/compare/v1.12.1...v1.12.2) (2026-07-29)
12
18
 
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
@@ -467,19 +486,6 @@ module Graphiti
467
486
  end
468
487
  end
469
488
 
470
- class InvalidWrapModel < Base
471
- def initialize(resource_class, model)
472
- @resource_class = resource_class
473
- @model = model
474
- end
475
-
476
- def message
477
- <<~MSG
478
- #{@resource_class.name}.wrap was given a #{@model.class}, but this resource is for #{@resource_class.model} (subclasses are fine).
479
- MSG
480
- end
481
- end
482
-
483
489
  class ResourceEndpointConflict < Base
484
490
  def initialize(path, action, resource_a, resource_b)
485
491
  @path = path
@@ -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,31 +50,11 @@ 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)
54
- end
55
-
56
- # Wrap models fetched outside graphiti so they render like any other proxy
57
- def wrap(models, base_scope = nil)
58
- validate_wrap_models!(models)
59
- runner = Runner.new(self, {}, nil, :find)
60
- runner.proxy(base_scope, bypass_required_filters: true).tap do |proxy|
61
- proxy.data = models
62
- end
53
+ runner.proxy(base_scope, single: true, raise_on_missing: true, assign_action: :create)
63
54
  end
64
55
 
65
56
  private
66
57
 
67
- # Skip polymorphic parents - resource_for_model raises a better error for unknown children
68
- def validate_wrap_models!(models)
69
- return if abstract_class? || (polymorphic? && !polymorphic_child?)
70
-
71
- [models].flatten.compact.each do |model|
72
- unless model.is_a?(self.model)
73
- raise Errors::InvalidWrapModel.new(self, model)
74
- end
75
- end
76
- end
77
-
78
58
  def caching_options
79
59
  {cache: @cache_resource, cache_expires_in: @cache_expires_in, cache_tag: @cache_tag}
80
60
  end
@@ -69,46 +69,57 @@ module Graphiti
69
69
  end
70
70
  end
71
71
 
72
- def create(create_params, meta = nil)
73
- model_instance = nil
74
- snapshot = attributes_snapshot(:create, create_params)
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)
88
+ end
89
+ call_with_meta(:assign_attributes, model_instance, params, meta)
90
+ model_instance
91
+ end
75
92
 
76
- run_callbacks :persistence, :create, create_params, meta do
77
- warn_attributes_mutated_in_around_persistence(:create, snapshot, create_params)
93
+ model_instance
94
+ end
78
95
 
79
- run_callbacks :attributes, :create, create_params, meta do |params|
80
- model_instance = call_with_meta(:build, model, meta)
81
- call_with_meta(:assign_attributes, model_instance, params, meta)
82
- model_instance
83
- end
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)
84
102
 
103
+ run_callbacks :persistence, :create, model_instance, meta do
85
104
  run_callbacks :save, :create, model_instance, meta do
86
105
  model_instance = call_with_meta(:save, model_instance, meta)
87
106
  end
88
107
 
89
108
  model_instance
90
109
  end
91
- end
92
-
93
- def update(update_params, meta = nil)
94
- model_instance = nil
95
- id = update_params[:id]
96
- update_params = update_params.except(:id)
97
-
98
- snapshot = attributes_snapshot(:update, update_params)
99
110
 
100
- run_callbacks :persistence, :update, update_params, meta do
101
- warn_attributes_mutated_in_around_persistence(:update, snapshot, update_params)
111
+ model_instance
112
+ end
102
113
 
103
- run_callbacks :attributes, :update, update_params, meta do |params|
104
- model_instance = self.class._find(id: id).data
105
- call_with_meta(:assign_attributes, model_instance, params, meta)
106
- model_instance
107
- end
114
+ def update(update_params, meta = nil, assigned_model: nil)
115
+ model_instance = assigned_model || assign(update_params, meta, :update)
108
116
 
117
+ run_callbacks :persistence, :update, model_instance, meta do
109
118
  run_callbacks :save, :update, model_instance, meta do
110
119
  model_instance = call_with_meta(:save, model_instance, meta)
111
120
  end
121
+
122
+ model_instance
112
123
  end
113
124
 
114
125
  model_instance
@@ -140,37 +151,6 @@ module Graphiti
140
151
 
141
152
  private
142
153
 
143
- # In Graphiti 2.0, attributes are assigned to the model before the
144
- # persistence hooks fire, so hash modifications made by an
145
- # around_persistence hook before its yield will no longer be applied.
146
- # Snapshot-and-compare detects exactly those hooks: the comparison
147
- # happens before any attributes-phase callback has run, so hooks that
148
- # only wrap their yield never trigger the warning.
149
- def attributes_snapshot(action, params)
150
- return unless around_persistence_hooks?(action)
151
- Util::Hash.deep_dup(params)
152
- end
153
-
154
- def around_persistence_hooks?(action)
155
- hooks = self.class.config[:callbacks][:persistence].try(:[], :around) || []
156
- hooks.any? { |hook| hook[:only].include?(action) }
157
- end
158
-
159
- def warn_attributes_mutated_in_around_persistence(action, snapshot, params)
160
- return if snapshot.nil? || snapshot == params
161
-
162
- changed_keys = (snapshot.keys | params.keys).reject { |key| snapshot[key] == params[key] }
163
- Graphiti::DEPRECATOR.warn(<<~MSG)
164
- #{self.class}'s around_persistence hook modified the attributes hash before yield (changed keys: #{changed_keys.map(&:inspect).join(", ")}). In Graphiti 2.0, attributes are assigned to the model before persistence hooks run, and these modifications will be silently ignored. Move the modification to a hook that runs before assignment:
165
-
166
- before_attributes do |attributes|
167
- attributes[#{changed_keys.first.inspect}] = ...
168
- end
169
-
170
- or set the value on the model itself in a before_save hook. See UPGRADING.md in the Graphiti 2.0 release.
171
- MSG
172
- end
173
-
174
154
  def run_callbacks(kind, action, *args)
175
155
  fire_around_callbacks(kind, action, *args) do |*yieldargs|
176
156
  fire_callbacks(kind, :before, action, *yieldargs)
@@ -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
@@ -80,14 +82,11 @@ module Graphiti
80
82
  Renderer.new(self, options).as_graphql
81
83
  end
82
84
 
83
- # Records supplied directly, no scope resolution
84
- def data=(models)
85
- @data = models
86
- [@data].flatten.compact.each { |record| @resource.decorate_record(record) }
87
- end
88
-
89
85
  def data
90
- @data ||= begin
86
+ return @data unless @data.nil?
87
+ return assign_attributes(@payload.params) if @assign_action
88
+
89
+ @data = begin
91
90
  records = @scope.resolve
92
91
  raise Graphiti::Errors::RecordNotFound if records.empty? && raise_on_missing?
93
92
 
@@ -95,6 +94,7 @@ module Graphiti
95
94
  records
96
95
  end
97
96
  end
97
+
98
98
  alias_method :to_a, :data
99
99
  alias_method :resolve_data, :data
100
100
 
@@ -137,18 +137,56 @@ module Graphiti
137
137
  @pagination ||= Delegates::Pagination.new(self)
138
138
  end
139
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
+
140
172
  def save(action: :create)
141
173
  # TODO: remove this. Only used for persisting many-to-many with AR
142
174
  # (see activerecord adapter)
143
175
  original = Graphiti.context[:namespace]
144
176
  begin
145
177
  Graphiti.context[:namespace] = action
146
- ::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
147
184
  validator = persist {
148
185
  @resource.persist_with_relationships \
149
186
  @payload.meta(action: action),
150
187
  @payload.attributes,
151
- @payload.relationships
188
+ @payload.relationships,
189
+ assigned_model: @assigned_model
152
190
  }
153
191
  ensure
154
192
  Graphiti.context[:namespace] = original
@@ -185,12 +223,16 @@ module Graphiti
185
223
  success
186
224
  end
187
225
 
188
- 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
189
231
  resolve_data
190
232
  save(action: :update)
191
233
  end
192
234
 
193
- alias update_attributes update # standard:disable Style/Alias
235
+ alias_method :update_attributes, :update
194
236
 
195
237
  def include_hash
196
238
  @include_hash ||= begin
@@ -247,6 +289,39 @@ module Graphiti
247
289
 
248
290
  private
249
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
+
250
325
  def persist
251
326
  transaction_response = @resource.transaction do
252
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.13.0"
2
+ VERSION = "2.0.0.beta.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphiti
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.13.0
4
+ version: 2.0.0.beta.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lee Richmond
@@ -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: "../"