graphiti 1.13.4 → 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.
@@ -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]
@@ -31,28 +31,6 @@ module Graphiti
31
31
  end
32
32
  end
33
33
 
34
- POOL_THREAD = :__graphiti_pool_thread
35
- private_constant :POOL_THREAD
36
-
37
- # A pool thread that waits on the pool deadlocks, since the task it waits for cannot start until the waiting thread frees its slot.
38
- def self.resolve_synchronously?
39
- !Graphiti.config.concurrency || on_pool_thread?
40
- end
41
-
42
- # TODO: move to Fiber[] once the floor is Ruby 3.2
43
- def self.on_pool_thread?
44
- Thread.current[POOL_THREAD] == true
45
- end
46
-
47
- # Restores rather than clears because :caller_runs may have run the task on a request thread.
48
- def self.marking_pool_thread
49
- previous = Thread.current[POOL_THREAD]
50
- Thread.current[POOL_THREAD] = true
51
- yield
52
- ensure
53
- Thread.current[POOL_THREAD] = previous
54
- end
55
-
56
34
  def initialize(object, resource, query, opts = {})
57
35
  @object = object
58
36
  @resource = resource
@@ -70,25 +48,29 @@ module Graphiti
70
48
  # Thread/Fiber storage snapshots, and Rails executor wrappers on every
71
49
  # request purely to drive a thread pool that is intentionally synchronous.
72
50
  # See https://github.com/graphiti-api/graphiti/issues/505
73
- if self.class.resolve_synchronously?
74
- sync_resolve(&blk)
75
- else
76
- future_resolve(&blk).value!
77
- end
51
+ return sync_resolve(&blk) unless Graphiti.config.concurrency
52
+
53
+ future_resolve.value!
78
54
  end
79
55
 
80
56
  def resolve_sideloads(results)
81
- if self.class.resolve_synchronously?
82
- sync_resolve_sideloads(results)
83
- else
84
- future_resolve_sideloads(results).value!
85
- end
57
+ return sync_resolve_sideloads(results) unless Graphiti.config.concurrency
58
+
59
+ future_resolve_sideloads(results).value!
86
60
  end
87
61
 
88
- def future_resolve(&blk)
62
+ def future_resolve
89
63
  return Concurrent::Promises.fulfilled_future([], self.class.global_thread_pool_executor) if @query.zero_results?
90
64
 
91
- resolved = resolve_primary_data(&blk)
65
+ resolved = broadcast_data { |payload|
66
+ @object = @resource.before_resolve(@object, @query)
67
+ payload[:results] = @resource.resolve(@object)
68
+ payload[:results]
69
+ }
70
+ resolved.compact!
71
+ assign_serializer(resolved)
72
+ yield resolved if block_given?
73
+ @opts[:after_resolve]&.call(resolved)
92
74
  sideloaded = @query.parents.any?
93
75
  close_adapter = Graphiti.config.concurrency && sideloaded
94
76
  if close_adapter
@@ -140,29 +122,12 @@ module Graphiti
140
122
 
141
123
  private
142
124
 
143
- def sync_resolve(&blk)
125
+ # Synchronous counterpart to #future_resolve, used when concurrency is off.
126
+ # Resolves the resource and its sideloads inline without any promise
127
+ # machinery. See #resolve.
128
+ def sync_resolve
144
129
  return [] if @query.zero_results?
145
130
 
146
- resolved = resolve_primary_data(&blk)
147
- sync_resolve_sideloads(resolved)
148
- resolved
149
- end
150
-
151
- def sync_resolve_sideloads(results)
152
- return if results == []
153
-
154
- each_applicable_sideload do |sideload, sideload_query|
155
- Graphiti.config.before_sideload&.call(Graphiti.context)
156
- sideload.resolve(results, sideload_query, @resource)
157
- end
158
-
159
- # resolve_sideloads is public, and without this it would return @query.sideloads itself,
160
- # where a delete would silently drop that sideload from the cache key.
161
- nil
162
- end
163
-
164
- # Runs inline on the calling thread in both the sync and future paths.
165
- def resolve_primary_data
166
131
  resolved = broadcast_data { |payload|
167
132
  @object = @resource.before_resolve(@object, @query)
168
133
  payload[:results] = @resource.resolve(@object)
@@ -172,28 +137,40 @@ module Graphiti
172
137
  assign_serializer(resolved)
173
138
  yield resolved if block_given?
174
139
  @opts[:after_resolve]&.call(resolved)
140
+ sync_resolve_sideloads(resolved) unless @query.sideloads.empty?
175
141
  resolved
176
142
  end
177
143
 
178
- def each_applicable_sideload
179
- @query.sideloads.each_pair do |name, sideload_query|
144
+ # Synchronous counterpart to #future_resolve_sideloads, used when
145
+ # concurrency is off. Resolves each sideload inline. See #resolve_sideloads.
146
+ def sync_resolve_sideloads(results)
147
+ return if results == []
148
+
149
+ @query.sideloads.each_pair do |name, q|
180
150
  sideload = @resource.class.sideload(name)
181
151
  next if sideload.nil? || sideload.shared_remote?
182
152
 
183
- yield sideload, sideload_query
153
+ Graphiti.config.before_sideload&.call(Graphiti.context)
154
+ sideload.resolve(results, q, @resource)
184
155
  end
156
+
157
+ # Match pre-1.8 semantics: the non-concurrent resolve_sideloads returned
158
+ # nil (not the sideloads Hash). Callers don't rely on the return value.
159
+ nil
185
160
  end
186
161
 
187
162
  def future_resolve_sideloads(results)
188
163
  return Concurrent::Promises.fulfilled_future(nil, self.class.global_thread_pool_executor) if results == []
189
164
 
190
- sideload_promises = []
191
- each_applicable_sideload do |sideload, sideload_query|
192
- promise = future_with_context(results, sideload_query, @resource) do |parent_results, future_query, parent_resource|
165
+ sideload_promises = @query.sideloads.filter_map do |name, q|
166
+ sideload = @resource.class.sideload(name)
167
+ next if sideload.nil? || sideload.shared_remote?
168
+
169
+ p = future_with_context(results, q, @resource) do |parent_results, sideload_query, parent_resource|
193
170
  Graphiti.config.before_sideload&.call(Graphiti.context)
194
- sideload.future_resolve(parent_results, future_query, parent_resource)
171
+ sideload.future_resolve(parent_results, sideload_query, parent_resource)
195
172
  end
196
- sideload_promises << promise.flat
173
+ p.flat
197
174
  end
198
175
 
199
176
  Concurrent::Promises.zip_futures_on(self.class.global_thread_pool_executor, *sideload_promises)
@@ -204,10 +181,7 @@ module Graphiti
204
181
  end
205
182
 
206
183
  def future_with_context(*args)
207
- # TODO: we only need Fiber.storage after Ruby 3.2 is the floor
208
184
  thread_storage = Thread.current.keys.each_with_object({}) do |key, memo|
209
- next if key == POOL_THREAD
210
-
211
185
  memo[key] = Thread.current[key]
212
186
  end
213
187
  fiber_storage =
@@ -220,13 +194,11 @@ module Graphiti
220
194
  Concurrent::Promises.future_on(
221
195
  self.class.global_thread_pool_executor, Thread.current.object_id, thread_storage, fiber_storage, *args
222
196
  ) do |thread_id, thread_storage, fiber_storage, *args|
223
- self.class.marking_pool_thread do
224
- wrap_in_rails_executor do
225
- with_thread_locals(thread_storage) do
226
- with_fiber_locals(fiber_storage) do
227
- Graphiti.broadcast(:global_thread_pool_task_run, self.class.global_thread_pool_stats) do
228
- yield(*args)
229
- end
197
+ wrap_in_rails_executor do
198
+ with_thread_locals(thread_storage) do
199
+ with_fiber_locals(fiber_storage) do
200
+ Graphiti.broadcast(:global_thread_pool_task_run, self.class.global_thread_pool_stats) do
201
+ yield(*args)
230
202
  end
231
203
  end
232
204
  end
@@ -209,9 +209,7 @@ module Graphiti
209
209
  end
210
210
 
211
211
  def parse_string_null(filter, value)
212
- return value unless filter[:allow_nil]
213
- return value.map { |item| item == "null" ? nil : item } if value.is_a?(Array)
214
- return if value == "null"
212
+ return if value == "null" && filter[:allow_nil]
215
213
 
216
214
  value
217
215
  end
@@ -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
@@ -108,42 +108,40 @@ class Graphiti::Sideload::PolymorphicBelongsTo < Graphiti::Sideload::BelongsTo
108
108
  end
109
109
 
110
110
  def resolve(parents, query, graph_parent)
111
- if ::Graphiti::Scope.resolve_synchronously?
112
- sync_resolve(parents, query, graph_parent)
113
- else
114
- future_resolve(parents, query, graph_parent).value!
115
- end
116
- end
111
+ return future_resolve(parents, query, graph_parent).value! if Graphiti.config.concurrency
117
112
 
118
- def future_resolve(parents, query, graph_parent)
119
- promises = []
120
- each_resolvable_group(parents, query) do |child, group, child_query|
121
- promises << child.future_resolve(group, child_query, graph_parent)
122
- end
123
- Concurrent::Promises.zip(*promises)
124
- end
125
-
126
- private
113
+ parents.group_by(&grouper.field_name).each_pair do |group_name, group|
114
+ next if group_name.nil? || grouper.ignore?(group_name)
127
115
 
128
- def sync_resolve(parents, query, graph_parent)
129
- each_resolvable_group(parents, query) do |child, group, child_query|
130
- child.resolve(group, child_query, graph_parent)
116
+ match = ->(c) { c.group_name == group_name.to_sym }
117
+ if (sideload = children.values.find(&match))
118
+ duped = remove_invalid_sideloads(sideload.resource, query)
119
+ sideload.resolve(group, duped, graph_parent)
120
+ else
121
+ err = ::Graphiti::Errors::PolymorphicSideloadChildNotFound
122
+ raise err.new(self, group_name)
123
+ end
131
124
  end
132
125
  end
133
126
 
134
- def each_resolvable_group(parents, query)
135
- parents.group_by(&grouper.field_name).each_pair do |group_name, group|
127
+ def future_resolve(parents, query, graph_parent)
128
+ promises = parents.group_by(&grouper.field_name).filter_map do |(group_name, group)|
136
129
  next if group_name.nil? || grouper.ignore?(group_name)
137
130
 
138
- child = children.values.find { |candidate| candidate.group_name == group_name.to_sym }
139
- unless child
140
- raise ::Graphiti::Errors::PolymorphicSideloadChildNotFound.new(self, group_name)
131
+ match = ->(c) { c.group_name == group_name.to_sym }
132
+ if (sideload = children.values.find(&match))
133
+ duped = remove_invalid_sideloads(sideload.resource, query)
134
+ sideload.future_resolve(group, duped, graph_parent)
135
+ else
136
+ err = ::Graphiti::Errors::PolymorphicSideloadChildNotFound
137
+ raise err.new(self, group_name)
141
138
  end
142
-
143
- yield child, group, remove_invalid_sideloads(child.resource, query)
144
139
  end
140
+ Concurrent::Promises.zip(*promises)
145
141
  end
146
142
 
143
+ private
144
+
147
145
  # We may be requesting a relationship that some subclasses support,
148
146
  # but not others. Remove anything we don't support.
149
147
  # TODO: spec to ensure this dupe logic doesn't mutate the original
@@ -240,11 +240,9 @@ module Graphiti
240
240
  end
241
241
 
242
242
  def load(parents, query, graph_parent)
243
- if Scope.resolve_synchronously?
244
- build_resource_proxy(parents, query, graph_parent).to_a
245
- else
246
- future_load(parents, query, graph_parent).value!
247
- end
243
+ return build_resource_proxy(parents, query, graph_parent).to_a unless Graphiti.config.concurrency
244
+
245
+ future_load(parents, query, graph_parent).value!
248
246
  end
249
247
 
250
248
  # Override in subclass
@@ -294,19 +292,48 @@ module Graphiti
294
292
  children.replace(associated) if track_associated
295
293
  end
296
294
 
295
+ # Synchronous counterpart to #future_resolve, used when concurrency is off.
296
+ # Mirrors #future_resolve but resolves inline via the synchronous
297
+ # Scope#resolve / #load paths (no promises). See Scope#sync_resolve_sideloads.
297
298
  def resolve(parents, query, graph_parent)
298
- if Scope.resolve_synchronously?
299
- sync_resolve(parents, query, graph_parent)
299
+ return future_resolve(parents, query, graph_parent).value! if Graphiti.config.concurrency
300
+
301
+ if single? && parents.length > 1
302
+ raise Errors::SingularSideload.new(self, parents.length)
303
+ end
304
+
305
+ if self.class.scope_proc
306
+ sideload_scope = fire_scope(parents)
307
+ sideload_scope = Scope.new sideload_scope,
308
+ resource,
309
+ query,
310
+ parent: graph_parent,
311
+ sideload: self,
312
+ sideload_parent_length: parents.length,
313
+ default_paginate: false
314
+ sideload_scope.resolve do |sideload_results|
315
+ fire_assign(parents, sideload_results)
316
+ end
300
317
  else
301
- future_resolve(parents, query, graph_parent).value!
318
+ load(parents, query, graph_parent)
302
319
  end
303
320
  end
304
321
 
305
322
  def future_resolve(parents, query, graph_parent)
306
- assert_singular!(parents)
323
+ if single? && parents.length > 1
324
+ raise Errors::SingularSideload.new(self, parents.length)
325
+ end
307
326
 
308
327
  if self.class.scope_proc
309
- build_sideload_scope(parents, query, graph_parent).future_resolve do |sideload_results|
328
+ sideload_scope = fire_scope(parents)
329
+ sideload_scope = Scope.new sideload_scope,
330
+ resource,
331
+ query,
332
+ parent: graph_parent,
333
+ sideload: self,
334
+ sideload_parent_length: parents.length,
335
+ default_paginate: false
336
+ sideload_scope.future_resolve do |sideload_results|
310
337
  fire_assign(parents, sideload_results)
311
338
  end
312
339
  else
@@ -374,34 +401,6 @@ module Graphiti
374
401
 
375
402
  private
376
403
 
377
- def sync_resolve(parents, query, graph_parent)
378
- assert_singular!(parents)
379
-
380
- if self.class.scope_proc
381
- build_sideload_scope(parents, query, graph_parent).resolve do |sideload_results|
382
- fire_assign(parents, sideload_results)
383
- end
384
- else
385
- load(parents, query, graph_parent)
386
- end
387
- end
388
-
389
- def assert_singular!(parents)
390
- if single? && parents.length > 1
391
- raise Errors::SingularSideload.new(self, parents.length)
392
- end
393
- end
394
-
395
- def build_sideload_scope(parents, query, graph_parent)
396
- Scope.new fire_scope(parents),
397
- resource,
398
- query,
399
- parent: graph_parent,
400
- sideload: self,
401
- sideload_parent_length: parents.length,
402
- default_paginate: false
403
- end
404
-
405
404
  def future_load(parents, query, graph_parent)
406
405
  proxy = build_resource_proxy(parents, query, graph_parent)
407
406
  proxy.respond_to?(:future_resolve_data) ? proxy.future_resolve_data : Concurrent::Promises.fulfilled_future(proxy)
@@ -82,10 +82,6 @@ module Graphiti
82
82
  Dry::Types["params.hash"][input]
83
83
  }
84
84
 
85
- ParamArray = Dry::Types["strict.array"].constructor { |input|
86
- input.is_a?(String) ? [input] : input
87
- }
88
-
89
85
  REQUIRED_KEYS = [:params, :read, :write, :kind, :description]
90
86
 
91
87
  def self.map
@@ -197,7 +193,7 @@ module Graphiti
197
193
 
198
194
  arrays[:"array_of_#{name.to_s.pluralize}"] = {
199
195
  canonical_name: name,
200
- params: ParamArray.of(map[:params]),
196
+ params: Dry::Types["strict.array"].of(map[:params]),
201
197
  read: Dry::Types["strict.array"].of(map[:read]),
202
198
  test: Dry::Types["strict.array"].of(map[:test]),
203
199
  write: Dry::Types["strict.array"].of(map[:write]),
@@ -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