stannum 0.2.0 → 0.4.0

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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +130 -1200
  4. data/config/locales/en.rb +4 -0
  5. data/lib/stannum/association.rb +293 -0
  6. data/lib/stannum/associations/many.rb +250 -0
  7. data/lib/stannum/associations/one.rb +106 -0
  8. data/lib/stannum/associations.rb +11 -0
  9. data/lib/stannum/attribute.rb +86 -8
  10. data/lib/stannum/constraints/base.rb +3 -5
  11. data/lib/stannum/constraints/enum.rb +1 -1
  12. data/lib/stannum/constraints/equality.rb +1 -1
  13. data/lib/stannum/constraints/format.rb +72 -0
  14. data/lib/stannum/constraints/hashes/extra_keys.rb +13 -13
  15. data/lib/stannum/constraints/hashes/indifferent_extra_keys.rb +47 -0
  16. data/lib/stannum/constraints/hashes.rb +6 -2
  17. data/lib/stannum/constraints/identity.rb +1 -1
  18. data/lib/stannum/constraints/properties/base.rb +124 -0
  19. data/lib/stannum/constraints/properties/do_not_match_property.rb +117 -0
  20. data/lib/stannum/constraints/properties/match_property.rb +117 -0
  21. data/lib/stannum/constraints/properties/matching.rb +112 -0
  22. data/lib/stannum/constraints/properties.rb +17 -0
  23. data/lib/stannum/constraints/signature.rb +2 -2
  24. data/lib/stannum/constraints/tuples/extra_items.rb +6 -6
  25. data/lib/stannum/constraints/type.rb +4 -4
  26. data/lib/stannum/constraints/types/array_type.rb +2 -2
  27. data/lib/stannum/constraints/types/hash_type.rb +4 -4
  28. data/lib/stannum/constraints/union.rb +1 -1
  29. data/lib/stannum/constraints/uuid.rb +30 -0
  30. data/lib/stannum/constraints.rb +3 -0
  31. data/lib/stannum/contract.rb +7 -7
  32. data/lib/stannum/contracts/array_contract.rb +2 -7
  33. data/lib/stannum/contracts/base.rb +15 -15
  34. data/lib/stannum/contracts/builder.rb +15 -4
  35. data/lib/stannum/contracts/hash_contract.rb +3 -9
  36. data/lib/stannum/contracts/indifferent_hash_contract.rb +15 -2
  37. data/lib/stannum/contracts/map_contract.rb +6 -10
  38. data/lib/stannum/contracts/parameters/arguments_contract.rb +1 -1
  39. data/lib/stannum/contracts/parameters/keywords_contract.rb +1 -1
  40. data/lib/stannum/contracts/parameters/signature_contract.rb +1 -1
  41. data/lib/stannum/contracts/parameters_contract.rb +4 -4
  42. data/lib/stannum/contracts/tuple_contract.rb +6 -6
  43. data/lib/stannum/entities/associations.rb +451 -0
  44. data/lib/stannum/entities/attributes.rb +316 -0
  45. data/lib/stannum/entities/constraints.rb +178 -0
  46. data/lib/stannum/entities/primary_key.rb +148 -0
  47. data/lib/stannum/entities/properties.rb +208 -0
  48. data/lib/stannum/entities.rb +16 -0
  49. data/lib/stannum/entity.rb +87 -0
  50. data/lib/stannum/errors.rb +12 -16
  51. data/lib/stannum/messages/default_strategy.rb +2 -2
  52. data/lib/stannum/parameter_validation.rb +10 -10
  53. data/lib/stannum/rspec/match_errors_matcher.rb +7 -7
  54. data/lib/stannum/rspec/validate_parameter.rb +2 -2
  55. data/lib/stannum/rspec/validate_parameter_matcher.rb +22 -20
  56. data/lib/stannum/schema.rb +117 -76
  57. data/lib/stannum/struct.rb +12 -346
  58. data/lib/stannum/support/optional.rb +1 -1
  59. data/lib/stannum/version.rb +4 -4
  60. data/lib/stannum.rb +6 -0
  61. metadata +26 -85
@@ -52,9 +52,9 @@ module Stannum::Contracts
52
52
  &block
53
53
  )
54
54
  super(
55
- allow_extra_keys: allow_extra_keys,
55
+ allow_extra_keys:,
56
56
  key_type: Stannum::Constraints::Hashes::IndifferentKey.new,
57
- value_type: value_type,
57
+ value_type:,
58
58
  **options,
59
59
  &block
60
60
  )
@@ -74,5 +74,18 @@ module Stannum::Contracts
74
74
  actual.fetch(property) { actual[property.to_s] }
75
75
  end
76
76
  end
77
+
78
+ private
79
+
80
+ def add_extra_keys_constraint
81
+ return if options[:allow_extra_keys]
82
+
83
+ keys = -> { expected_keys }
84
+
85
+ add_constraint(
86
+ Stannum::Constraints::Hashes::IndifferentExtraKeys.new(keys),
87
+ concatenatable: false
88
+ )
89
+ end
77
90
  end
78
91
  end
@@ -87,13 +87,13 @@ module Stannum::Contracts
87
87
  # @overload key(**options) { |value| }
88
88
  # Creates a new Stannum::Constraint object with the given block, and
89
89
  # adds that constraint to the contract for the value at the given key.
90
- def key(property, constraint = nil, **options, &block)
90
+ def key(property, constraint = nil, **options, &)
91
91
  self.constraint(
92
92
  constraint,
93
- property: property,
93
+ property:,
94
94
  property_type: :key,
95
95
  **options,
96
- &block
96
+ &
97
97
  )
98
98
  end
99
99
  end
@@ -107,11 +107,7 @@ module Stannum::Contracts
107
107
  **options,
108
108
  &block
109
109
  )
110
- super(
111
- allow_extra_keys: allow_extra_keys,
112
- **options,
113
- &block
114
- )
110
+ super
115
111
  end
116
112
 
117
113
  # Adds a key constraint to the contract.
@@ -136,7 +132,7 @@ module Stannum::Contracts
136
132
  constraint,
137
133
  property: key,
138
134
  property_type: :key,
139
- sanity: sanity,
135
+ sanity:,
140
136
  **options
141
137
  )
142
138
  end
@@ -188,7 +184,7 @@ module Stannum::Contracts
188
184
  add_constraint Stannum::Constraints::Signatures::Map.new, sanity: true
189
185
  end
190
186
 
191
- def define_constraints(&block)
187
+ def define_constraints(&)
192
188
  add_type_constraint
193
189
 
194
190
  add_extra_keys_constraint
@@ -103,7 +103,7 @@ module Stannum::Contracts::Parameters
103
103
  type = coerce_item_type(item_type)
104
104
  constraint = Stannum::Constraints::Types::ArrayType.new(item_type: type)
105
105
 
106
- set_variadic_constraint(constraint, as: as)
106
+ set_variadic_constraint(constraint, as:)
107
107
  end
108
108
 
109
109
  protected
@@ -105,7 +105,7 @@ module Stannum::Contracts::Parameters
105
105
  value_type: type
106
106
  )
107
107
 
108
- set_variadic_constraint(constraint, as: as)
108
+ set_variadic_constraint(constraint, as:)
109
109
  end
110
110
 
111
111
  protected
@@ -13,7 +13,7 @@ module Stannum::Contracts::Parameters
13
13
 
14
14
  private
15
15
 
16
- def define_constraints(&block)
16
+ def define_constraints(&)
17
17
  super
18
18
 
19
19
  add_key_constraint :arguments,
@@ -320,8 +320,8 @@ module Stannum::Contracts
320
320
  # constraint, otherwise false.
321
321
  #
322
322
  # @return [Stannum::Contracts::ParametersContract::Builder] the builder.
323
- def keyword(name, type = nil, **options, &block)
324
- type = resolve_constraint_or_type(type, **options, &block)
323
+ def keyword(name, type = nil, **options, &)
324
+ type = resolve_constraint_or_type(type, **options, &)
325
325
 
326
326
  contract.add_keyword_constraint(
327
327
  name,
@@ -515,8 +515,8 @@ module Stannum::Contracts
515
515
  end
516
516
  end
517
517
 
518
- def define_constraints(&block)
519
- super(&block)
518
+ def define_constraints(&)
519
+ super
520
520
 
521
521
  add_key_constraint :arguments, arguments_contract
522
522
  add_key_constraint :keywords, keywords_contract
@@ -99,7 +99,7 @@ module Stannum::Contracts
99
99
  #
100
100
  # @param options [Hash<Symbol, Object>] Options for the constraint.
101
101
  # @yieldparam value [Object] The value of the property when called.
102
- def item(constraint = nil, **options, &block)
102
+ def item(constraint = nil, **options, &)
103
103
  index = (@current_index += 1)
104
104
 
105
105
  self.constraint(
@@ -107,7 +107,7 @@ module Stannum::Contracts
107
107
  property: index,
108
108
  property_type: :index,
109
109
  **options,
110
- &block
110
+ &
111
111
  )
112
112
  end
113
113
  end
@@ -117,7 +117,7 @@ module Stannum::Contracts
117
117
  # @param options [Hash<Symbol, Object>] Configuration options for the
118
118
  # contract. Defaults to an empty Hash.
119
119
  def initialize(allow_extra_items: false, **options, &block)
120
- super(allow_extra_items: allow_extra_items, **options, &block)
120
+ super
121
121
  end
122
122
 
123
123
  # Adds an index constraint to the contract.
@@ -142,7 +142,7 @@ module Stannum::Contracts
142
142
  constraint,
143
143
  property: index,
144
144
  property_type: :index,
145
- sanity: sanity,
145
+ sanity:,
146
146
  **options
147
147
  )
148
148
  end
@@ -168,7 +168,7 @@ module Stannum::Contracts
168
168
 
169
169
  index = 1 + definition.options.fetch(:property, -1)
170
170
 
171
- index > count ? index : count
171
+ [index, count].max
172
172
  end
173
173
  end
174
174
 
@@ -202,7 +202,7 @@ module Stannum::Contracts
202
202
  add_constraint Stannum::Constraints::Signatures::Tuple.new, sanity: true
203
203
  end
204
204
 
205
- def define_constraints(&block)
205
+ def define_constraints(&)
206
206
  add_type_constraint
207
207
 
208
208
  add_extra_items_constraint
@@ -0,0 +1,451 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/entities'
4
+ require 'stannum/schema'
5
+
6
+ module Stannum::Entities
7
+ # Methods for defining and accessing entity associations.
8
+ module Associations # rubocop:disable Metrics/ModuleLength
9
+ # Class methods to extend the class when including Associations.
10
+ module ClassMethods # rubocop:disable Metrics/ModuleLength
11
+ START_WITH_CAPITAL_LETTER = /\A[A-Z]/
12
+ private_constant :START_WITH_CAPITAL_LETTER
13
+
14
+ # Defines an association on the entity.
15
+ #
16
+ # When an association is defined, each of the following steps is executed:
17
+ #
18
+ # - Adds the association to ::Associations and the .associations class
19
+ # method.
20
+ # - Adds the association to #association and the associated methods, such
21
+ # as #assign_associations, #[] and #[]=.
22
+ # - Defines reader and writer methods.
23
+ #
24
+ # @overload association(arity, assoc_name, **options)
25
+ # Defines an association with the given name. The class of the
26
+ # associated object is determined automatically based on the association
27
+ # name, or can be specified with the :class_name keyword.
28
+ #
29
+ # @param arity [:one, :many] :one if the association has one item, or
30
+ # :many if the association can have multiple items.
31
+ # @param assoc_name [String, Symbol] the name of the association.
32
+ # @param options [Hash] additional options for the association.
33
+ #
34
+ # @option options [String] :class_name the name of the associated class.
35
+ # @option options [true, Hash] :foreign_key the foreign key options for
36
+ # the association. Can be true, or a Hash containing :name and/or
37
+ # :type keys.
38
+ #
39
+ # @return [Symbol] the association name as a symbol.
40
+ #
41
+ # @overload association(arity, assoc_type, **options)
42
+ # Defines an association with the given class. The name of the
43
+ # association is determined automatically based on the association
44
+ # class.
45
+ #
46
+ # @param arity [:one, :many] :one if the association has one item, or
47
+ # :many if the association can have multiple items.
48
+ # @param assoc_type [String, Symbol, Class] the type of the associated
49
+ # @param options [Hash] additional options for the association.
50
+ #
51
+ # @option options [true, Hash] :foreign_key the foreign key options for
52
+ # the association. Can be true, or a Hash containing :name and/or
53
+ # :type keys.
54
+ #
55
+ # @return [Symbol] the association name as a symbol.
56
+ def association(arity, class_or_name, **options) # rubocop:disable Metrics/MethodLength
57
+ assoc_class =
58
+ resolve_association_class(arity)
59
+ assoc_name, assoc_type, options =
60
+ resolve_parameters(arity, class_or_name, options)
61
+
62
+ association = associations.define(
63
+ definition_class: assoc_class,
64
+ name: assoc_name,
65
+ type: assoc_type,
66
+ options: parse_options(assoc_name, **options)
67
+ )
68
+ define_foreign_key(association) if association.foreign_key?
69
+
70
+ association.name.intern
71
+ end
72
+ alias define_association association
73
+
74
+ # @return [Stannum::Schema] The associations Schema object for the Entity.
75
+ def associations
76
+ self::Associations
77
+ end
78
+
79
+ # @return [Class] the default type for foreign key attributes.
80
+ def default_foreign_key_type
81
+ (defined?(primary_key_type) && primary_key_type) || Integer
82
+ end
83
+
84
+ private
85
+
86
+ def association_name_for(arity, class_or_name, configured) # rubocop:disable Metrics/MethodLength
87
+ if configured
88
+ raise ArgumentError,
89
+ %(ambiguous class name "#{class_or_name}" or "#{configured}" ) \
90
+ '- do not provide both a class and a :class_name keyword'
91
+ end
92
+
93
+ assoc_name = tools.string_tools.underscore(
94
+ class_or_name.to_s.split('::').last
95
+ )
96
+ assoc_name = tools.string_tools.singularize(assoc_name) if arity == :one
97
+ assoc_name = tools.string_tools.pluralize(assoc_name) if arity == :many
98
+
99
+ assoc_name
100
+ end
101
+
102
+ def class_name?(class_or_name)
103
+ START_WITH_CAPITAL_LETTER.match?(class_or_name)
104
+ end
105
+
106
+ def define_foreign_key(association)
107
+ define_attribute(
108
+ association.foreign_key_name,
109
+ association.foreign_key_type,
110
+ association_name: association.name,
111
+ foreign_key: true,
112
+ required: false
113
+ )
114
+ end
115
+
116
+ def included(other)
117
+ super
118
+
119
+ other.include(Stannum::Entities::Associations)
120
+
121
+ Stannum::Entities::Associations.apply(other) if other.is_a?(Class)
122
+ end
123
+
124
+ def inherited(other)
125
+ super
126
+
127
+ Stannum::Entities::Associations.apply(other)
128
+ end
129
+
130
+ def parse_foreign_key_options(assoc_name, foreign_key) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
131
+ return {} if foreign_key == false
132
+
133
+ foreign_key = {} if foreign_key == true
134
+
135
+ if foreign_key.is_a?(String) || foreign_key.is_a?(Symbol)
136
+ foreign_key = { name: foreign_key.to_s }
137
+ end
138
+
139
+ unless foreign_key.is_a?(Hash)
140
+ raise InvalidOptionError, "invalid foreign key #{foreign_key.inspect}"
141
+ end
142
+
143
+ name = foreign_key.fetch(:name) { "#{assoc_name}_id" }
144
+ type = foreign_key.fetch(:type) { default_foreign_key_type }
145
+
146
+ {
147
+ foreign_key_name: name,
148
+ foreign_key_type: type
149
+ }
150
+ end
151
+
152
+ def parse_inverse_options(inverse)
153
+ hsh = {
154
+ entity_class_name: name,
155
+ inverse: true
156
+ }
157
+
158
+ if inverse.is_a?(String) || inverse.is_a?(Symbol)
159
+ hsh[:inverse_name] = inverse.to_s
160
+ end
161
+
162
+ hsh
163
+ end
164
+
165
+ def parse_options(assoc_name, **options) # rubocop:disable Metrics/MethodLength
166
+ if options.key?(:foreign_key)
167
+ options = options.merge(
168
+ parse_foreign_key_options(assoc_name, options.delete(:foreign_key))
169
+ )
170
+ end
171
+
172
+ if options[:inverse] != false
173
+ options = options.merge(
174
+ parse_inverse_options(options.delete(:inverse))
175
+ )
176
+ end
177
+
178
+ options
179
+ end
180
+
181
+ def resolve_association_class(arity)
182
+ return Stannum::Associations::One if arity == :one
183
+
184
+ return Stannum::Associations::Many if arity == :many
185
+
186
+ raise ArgumentError, 'association arity must be :one or :many'
187
+ end
188
+
189
+ def resolve_association_type(assoc_name)
190
+ tools.string_tools.chain(assoc_name, :singularize, :camelize)
191
+ end
192
+
193
+ def resolve_parameters(arity, class_or_name, options)
194
+ class_name = options.delete(:class_name)
195
+
196
+ if class_or_name.is_a?(Module) || class_name?(class_or_name)
197
+ assoc_name = association_name_for(arity, class_or_name, class_name)
198
+ assoc_type = class_or_name
199
+
200
+ return [assoc_name, assoc_type, options]
201
+ end
202
+
203
+ assoc_name = tools.string_tools.underscore(class_or_name.to_s)
204
+ assoc_type = class_name || resolve_association_type(assoc_name)
205
+
206
+ [assoc_name, assoc_type, options]
207
+ end
208
+
209
+ def tools
210
+ SleepingKingStudios::Tools::Toolbelt.instance
211
+ end
212
+ end
213
+
214
+ # Exception class raised when an invalid option value is set.
215
+ class InvalidOptionError < StandardError; end
216
+
217
+ class << self
218
+ # Generates Associations schema for the class.
219
+ #
220
+ # Creates a new Stannum::Schema and sets it as the class's :Associations
221
+ # constant. If the superclass is an entity class (and already defines its
222
+ # own Associations, includes the superclass Associations in the class
223
+ # Associations). Finally, includes the class Associations in the class.
224
+ #
225
+ # @param other [Class] the class to which attributes are added.
226
+ def apply(other)
227
+ return unless other.is_a?(Class)
228
+
229
+ return if entity_class?(other)
230
+
231
+ other.const_set(:Associations, build_schema)
232
+
233
+ if entity_class?(other.superclass)
234
+ other::Associations.include(other.superclass::Associations)
235
+ end
236
+
237
+ other.include(other::Associations)
238
+ end
239
+
240
+ private
241
+
242
+ def build_schema
243
+ Stannum::Schema.new(
244
+ property_class: Stannum::Association,
245
+ property_name: 'associations'
246
+ )
247
+ end
248
+
249
+ def entity_class?(other)
250
+ other.const_defined?(:Associations, false)
251
+ end
252
+
253
+ def included(other)
254
+ super
255
+
256
+ other.extend(self::ClassMethods)
257
+
258
+ apply(other) if other.is_a?(Class)
259
+ end
260
+ end
261
+
262
+ # @param properties [Hash] the properties used to initialize the entity.
263
+ def initialize(**properties)
264
+ @associations = {}
265
+ @association_proxies = {}
266
+
267
+ super
268
+ end
269
+
270
+ # Updates the struct's associations with the given values.
271
+ #
272
+ # This method is used to update some (but not all) of the associations of
273
+ # the struct. For each key in the hash, it calls the corresponding writer
274
+ # method with the value for that association.
275
+ #
276
+ # Any associations that are not in the given hash are unchanged, as are any
277
+ # properties that are not associations.
278
+ #
279
+ # If the associations hash includes any keys that do not correspond to an
280
+ # association, the struct will raise an error.
281
+ #
282
+ # @param associations [Hash] The associations for the struct.
283
+ #
284
+ # @raise ArgumentError if any key is not a valid association.
285
+ #
286
+ # @see #associations=
287
+ def assign_associations(associations)
288
+ unless associations.is_a?(Hash)
289
+ raise ArgumentError, 'associations must be a Hash'
290
+ end
291
+
292
+ set_associations(associations, force: false)
293
+ end
294
+
295
+ # @private
296
+ def association_proxy_for(association)
297
+ @association_proxies[association.name] ||=
298
+ Stannum::Associations::Many::Proxy.new(association:, entity: self)
299
+ end
300
+
301
+ # Collects the entity associations.
302
+ #
303
+ # @return [Hash<String, Object>] the entity associations.
304
+ def associations
305
+ @associations.dup
306
+ end
307
+
308
+ # Replaces the entity's associations with the given values.
309
+ #
310
+ # This method is used to update all of the associations of the entity. For
311
+ # each association, the writer method is called with the value from the
312
+ # hash. Non-association properties are unchanged.
313
+ #
314
+ # If the associations hash includes any keys that do not correspond to a
315
+ # valid association, the entity will raise an error.
316
+ #
317
+ # @param associations [Hash] the associations to assign to the entity.
318
+ #
319
+ # @raise ArgumentError if any key is not a valid association.
320
+ #
321
+ # @see #assign_attributes
322
+ def associations=(associations)
323
+ unless associations.is_a?(Hash)
324
+ raise ArgumentError, 'associations must be a Hash'
325
+ end
326
+
327
+ set_associations(associations, force: true)
328
+ end
329
+
330
+ # (see Stannum::Entities::Properties#properties)
331
+ def properties
332
+ super.merge(associations)
333
+ end
334
+
335
+ # Retrieves the association value for the requested key.
336
+ #
337
+ # If the :safe flag is set, will verify that the association name is valid
338
+ # (a non-empty String or Symbol) and that there is a defined association by
339
+ # that name. By default, :safe is set to true.
340
+ #
341
+ # @param key [String, Symbol] the key of the association to retrieve.
342
+ # @param safe [Boolean] if true, validates the association key.
343
+ #
344
+ # @return [Object] the value of the requested association.
345
+ #
346
+ # @api private
347
+ def read_association(key, safe: true)
348
+ if safe
349
+ tools.assertions.validate_name(key, as: 'association')
350
+
351
+ unless self.class.associations.key?(key.to_s)
352
+ raise ArgumentError, "unknown association #{key.inspect}"
353
+ end
354
+ end
355
+
356
+ @associations[key.to_s]
357
+ end
358
+
359
+ # Assigns the association value for the requested key.
360
+ #
361
+ # If the :safe flag is set, will verify that the association name is valid
362
+ # (a non-empty String or Symbol) and that there is a defined association by
363
+ # that name. By default, :safe is set to true.
364
+ #
365
+ # @param key [String, Symbol] the key of the association to assign.
366
+ # @param value [Object] the value to assign.
367
+ # @param safe [Boolean] if true, validates the association key.
368
+ #
369
+ # @return [Object] the assigned value.
370
+ #
371
+ # @api private
372
+ def write_association(key, value, safe: true)
373
+ if safe
374
+ tools.assertions.validate_name(key, as: 'association')
375
+
376
+ unless self.class.associations.key?(key.to_s)
377
+ raise ArgumentError, "unknown association #{key.inspect}"
378
+ end
379
+ end
380
+
381
+ @associations[key.to_s] = value
382
+ end
383
+
384
+ private
385
+
386
+ def get_property(key)
387
+ return @associations[key.to_s] if associations.key?(key.to_s)
388
+
389
+ super
390
+ end
391
+
392
+ def inspect_association(value, **options) # rubocop:disable Metrics/MethodLength
393
+ if value.nil?
394
+ 'nil'
395
+ elsif value.is_a?(Array)
396
+ value
397
+ .map { |item| inspect_association(item, **options) }
398
+ .join(', ')
399
+ .then { |str| "[#{str}]" }
400
+ elsif value.respond_to?(:inspect_with_options)
401
+ value.inspect_with_options(**options)
402
+ else
403
+ value.inspect
404
+ end
405
+ end
406
+
407
+ def inspect_properties(**options)
408
+ return super unless options.fetch(:associations, true)
409
+
410
+ @associations.reduce(super) do |memo, (key, value)|
411
+ mapped = inspect_association(value, **options, associations: false)
412
+
413
+ "#{memo} #{key}: #{mapped}"
414
+ end
415
+ end
416
+
417
+ def set_associations(associations, force:)
418
+ associations, non_matching =
419
+ bisect_properties(associations, self.class.associations)
420
+
421
+ unless non_matching.empty?
422
+ handle_invalid_properties(non_matching, as: 'association')
423
+ end
424
+
425
+ write_associations(associations, force:)
426
+ end
427
+
428
+ def set_properties(properties, force:)
429
+ associations, non_matching =
430
+ bisect_properties(properties, self.class.associations)
431
+
432
+ super(non_matching, force:)
433
+
434
+ write_associations(associations, force:)
435
+ end
436
+
437
+ def set_property(key, value)
438
+ return super unless associations.key?(key.to_s)
439
+
440
+ send(self.class.associations[key.to_s].writer_name, value)
441
+ end
442
+
443
+ def write_associations(associations, force:)
444
+ self.class.associations.each do |assoc_name, association|
445
+ next unless associations.key?(assoc_name) || force
446
+
447
+ send(association.writer_name, associations[assoc_name])
448
+ end
449
+ end
450
+ end
451
+ end