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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +130 -1200
- data/config/locales/en.rb +4 -0
- data/lib/stannum/association.rb +293 -0
- data/lib/stannum/associations/many.rb +250 -0
- data/lib/stannum/associations/one.rb +106 -0
- data/lib/stannum/associations.rb +11 -0
- data/lib/stannum/attribute.rb +86 -8
- data/lib/stannum/constraints/base.rb +3 -5
- data/lib/stannum/constraints/enum.rb +1 -1
- data/lib/stannum/constraints/equality.rb +1 -1
- data/lib/stannum/constraints/format.rb +72 -0
- data/lib/stannum/constraints/hashes/extra_keys.rb +13 -13
- data/lib/stannum/constraints/hashes/indifferent_extra_keys.rb +47 -0
- data/lib/stannum/constraints/hashes.rb +6 -2
- data/lib/stannum/constraints/identity.rb +1 -1
- data/lib/stannum/constraints/properties/base.rb +124 -0
- data/lib/stannum/constraints/properties/do_not_match_property.rb +117 -0
- data/lib/stannum/constraints/properties/match_property.rb +117 -0
- data/lib/stannum/constraints/properties/matching.rb +112 -0
- data/lib/stannum/constraints/properties.rb +17 -0
- data/lib/stannum/constraints/signature.rb +2 -2
- data/lib/stannum/constraints/tuples/extra_items.rb +6 -6
- data/lib/stannum/constraints/type.rb +4 -4
- data/lib/stannum/constraints/types/array_type.rb +2 -2
- data/lib/stannum/constraints/types/hash_type.rb +4 -4
- data/lib/stannum/constraints/union.rb +1 -1
- data/lib/stannum/constraints/uuid.rb +30 -0
- data/lib/stannum/constraints.rb +3 -0
- data/lib/stannum/contract.rb +7 -7
- data/lib/stannum/contracts/array_contract.rb +2 -7
- data/lib/stannum/contracts/base.rb +15 -15
- data/lib/stannum/contracts/builder.rb +15 -4
- data/lib/stannum/contracts/hash_contract.rb +3 -9
- data/lib/stannum/contracts/indifferent_hash_contract.rb +15 -2
- data/lib/stannum/contracts/map_contract.rb +6 -10
- data/lib/stannum/contracts/parameters/arguments_contract.rb +1 -1
- data/lib/stannum/contracts/parameters/keywords_contract.rb +1 -1
- data/lib/stannum/contracts/parameters/signature_contract.rb +1 -1
- data/lib/stannum/contracts/parameters_contract.rb +4 -4
- data/lib/stannum/contracts/tuple_contract.rb +6 -6
- data/lib/stannum/entities/associations.rb +451 -0
- data/lib/stannum/entities/attributes.rb +316 -0
- data/lib/stannum/entities/constraints.rb +178 -0
- data/lib/stannum/entities/primary_key.rb +148 -0
- data/lib/stannum/entities/properties.rb +208 -0
- data/lib/stannum/entities.rb +16 -0
- data/lib/stannum/entity.rb +87 -0
- data/lib/stannum/errors.rb +12 -16
- data/lib/stannum/messages/default_strategy.rb +2 -2
- data/lib/stannum/parameter_validation.rb +10 -10
- data/lib/stannum/rspec/match_errors_matcher.rb +7 -7
- data/lib/stannum/rspec/validate_parameter.rb +2 -2
- data/lib/stannum/rspec/validate_parameter_matcher.rb +22 -20
- data/lib/stannum/schema.rb +117 -76
- data/lib/stannum/struct.rb +12 -346
- data/lib/stannum/support/optional.rb +1 -1
- data/lib/stannum/version.rb +4 -4
- data/lib/stannum.rb +6 -0
- metadata +26 -85
data/config/locales/en.rb
CHANGED
@@ -7,6 +7,7 @@
|
|
7
7
|
absent: 'is nil or empty',
|
8
8
|
anything: 'is a value',
|
9
9
|
does_not_have_methods: 'does not respond to the methods',
|
10
|
+
does_not_match_format: 'does not match the expected format',
|
10
11
|
has_methods: 'responds to the methods',
|
11
12
|
hashes: {
|
12
13
|
extra_keys: 'has extra keys',
|
@@ -15,10 +16,12 @@
|
|
15
16
|
no_extra_keys: 'does not have extra keys'
|
16
17
|
},
|
17
18
|
invalid: 'is invalid',
|
19
|
+
is_a_uuid: 'is a valid UUID',
|
18
20
|
is_boolean: 'is true or false',
|
19
21
|
is_in_list: 'is in the list',
|
20
22
|
is_in_union: 'matches one of the constraints',
|
21
23
|
is_equal_to: 'is equal to',
|
24
|
+
is_not_a_uuid: 'is not a valid UUID',
|
22
25
|
is_not_boolean: 'is not true or false',
|
23
26
|
is_not_equal_to: 'is not equal to',
|
24
27
|
is_not_in_list: 'is not in the list',
|
@@ -39,6 +42,7 @@
|
|
39
42
|
end
|
40
43
|
end,
|
41
44
|
is_value: 'is the expected value',
|
45
|
+
matches_format: 'matches the expected format',
|
42
46
|
parameters: {
|
43
47
|
extra_arguments: 'has extra arguments',
|
44
48
|
extra_keywords: 'has extra keywords',
|
@@ -0,0 +1,293 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'stannum'
|
4
|
+
|
5
|
+
module Stannum
|
6
|
+
# Data object representing an association on an entity.
|
7
|
+
class Association # rubocop:disable Metrics/ClassLength
|
8
|
+
# Exception raised when calling an abstract method.
|
9
|
+
class AbstractAssociationError < StandardError; end
|
10
|
+
|
11
|
+
# Exception raised when referencing an invalid inverse association..
|
12
|
+
class InverseAssociationError < StandardError; end
|
13
|
+
|
14
|
+
# Builder class for defining association methods on an entity.
|
15
|
+
class Builder
|
16
|
+
# @param schema [Stannum::Schema] the associations schema on which to
|
17
|
+
# define methods.
|
18
|
+
def initialize(schema)
|
19
|
+
@schema = schema
|
20
|
+
end
|
21
|
+
|
22
|
+
# @return [Stannum::Schema] the associations schema on which to define
|
23
|
+
# methods.
|
24
|
+
attr_reader :schema
|
25
|
+
|
26
|
+
# Defines the reader and writer methods for the association.
|
27
|
+
#
|
28
|
+
# @param association [Stannum::Association]
|
29
|
+
def call(association)
|
30
|
+
define_reader(association)
|
31
|
+
define_writer(association)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def define_reader(association)
|
37
|
+
schema.define_method(association.reader_name) do
|
38
|
+
association.get_value(self)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def define_writer(association)
|
43
|
+
schema.define_method(association.writer_name) do |value|
|
44
|
+
association.clear_value(self)
|
45
|
+
|
46
|
+
association.set_value(self, value)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# @param name [String, Symbol] The name of the association. Converted to a
|
52
|
+
# String.
|
53
|
+
# @param options [Hash, nil] Options for the association. Converted to a
|
54
|
+
# Hash with Symbol keys. Defaults to an empty Hash.
|
55
|
+
# @param type [Class, Module, String] The type of the association. Can be a
|
56
|
+
# Class, a Module, or the name of a class or module.
|
57
|
+
def initialize(name:, options:, type:)
|
58
|
+
validate_name(name)
|
59
|
+
validate_options(options)
|
60
|
+
validate_type(type)
|
61
|
+
|
62
|
+
@name = name.to_s
|
63
|
+
@options = tools.hash_tools.convert_keys_to_symbols(options || {})
|
64
|
+
|
65
|
+
@type, @resolved_type = resolve_type(type)
|
66
|
+
end
|
67
|
+
|
68
|
+
# @return [String] the name of the association.
|
69
|
+
attr_reader :name
|
70
|
+
|
71
|
+
# @return [Hash] the association options.
|
72
|
+
attr_reader :options
|
73
|
+
|
74
|
+
# @return [String] the name of the association type Class or Module.
|
75
|
+
attr_reader :type
|
76
|
+
|
77
|
+
# @api private
|
78
|
+
#
|
79
|
+
# Adds the given value to the association for the entity.
|
80
|
+
#
|
81
|
+
# @param entity [Stannum::Entity] the entity to update.
|
82
|
+
# @param value [Object] the new value for the association.
|
83
|
+
# @param update_inverse [Boolean] if true, updates the inverse association
|
84
|
+
# (if any). Defaults to false.
|
85
|
+
#
|
86
|
+
# @return [void]
|
87
|
+
def add_value(entity, value, update_inverse: true) # rubocop:disable Lint/UnusedMethodArgument
|
88
|
+
raise AbstractAssociationError,
|
89
|
+
"#{self.class} is an abstract class - use an association subclass"
|
90
|
+
end
|
91
|
+
|
92
|
+
# @api private
|
93
|
+
#
|
94
|
+
# Removes the value of the association for the entity.
|
95
|
+
#
|
96
|
+
# @param entity [Stannum::Entity] the entity to update.
|
97
|
+
#
|
98
|
+
# @return [void]
|
99
|
+
def clear_value(entity, update_inverse: true) # rubocop:disable Lint/UnusedMethodArgument
|
100
|
+
raise AbstractAssociationError,
|
101
|
+
"#{self.class} is an abstract class - use an association subclass"
|
102
|
+
end
|
103
|
+
|
104
|
+
# @return [String, nil] the name of the original entity class.
|
105
|
+
def entity_class_name
|
106
|
+
@options[:entity_class_name]
|
107
|
+
end
|
108
|
+
|
109
|
+
# @return [true, false] true if the association has defines a foreign key;
|
110
|
+
# otherwise false.
|
111
|
+
def foreign_key?
|
112
|
+
false
|
113
|
+
end
|
114
|
+
|
115
|
+
# @return [String?] the name of the foreign key, if any.
|
116
|
+
def foreign_key_name
|
117
|
+
nil
|
118
|
+
end
|
119
|
+
|
120
|
+
# @return [Class, Stannum::Constraint, nil] the type of the foreign key, if
|
121
|
+
# any.
|
122
|
+
def foreign_key_type
|
123
|
+
nil
|
124
|
+
end
|
125
|
+
|
126
|
+
# @api private
|
127
|
+
#
|
128
|
+
# Retrieves the value of the association for the entity.
|
129
|
+
#
|
130
|
+
# @param entity [Stannum::Entity] the entity to update.
|
131
|
+
#
|
132
|
+
# @return [Object] the value of the association.
|
133
|
+
def get_value(entity) # rubocop:disable Lint/UnusedMethodArgument
|
134
|
+
raise AbstractAssociationError,
|
135
|
+
"#{self.class} is an abstract class - use an association subclass"
|
136
|
+
end
|
137
|
+
|
138
|
+
# @return [Boolean] true if the association has an inverse association;
|
139
|
+
# otherwise false.
|
140
|
+
def inverse?
|
141
|
+
!!@options[:inverse]
|
142
|
+
end
|
143
|
+
|
144
|
+
# @return [String] the name of the inverse association, if any.
|
145
|
+
def inverse_name
|
146
|
+
@inverse_name ||= resolve_inverse_name
|
147
|
+
end
|
148
|
+
|
149
|
+
# @return [false] true if the association is a plural association;
|
150
|
+
# otherwise false.
|
151
|
+
def many?
|
152
|
+
false
|
153
|
+
end
|
154
|
+
|
155
|
+
# @return [false] true if the association is a singular association;
|
156
|
+
# otherwise false.
|
157
|
+
def one?
|
158
|
+
false
|
159
|
+
end
|
160
|
+
|
161
|
+
# @return [Symbol] the name of the reader method for the association.
|
162
|
+
def reader_name
|
163
|
+
@reader_name ||= name.intern
|
164
|
+
end
|
165
|
+
|
166
|
+
# @api private
|
167
|
+
#
|
168
|
+
# Removes the given value from the association for the entity.
|
169
|
+
#
|
170
|
+
# @param entity [Stannum::Entity] the entity to update.
|
171
|
+
# @param value [Stannum::Entity] the association value to remove.
|
172
|
+
# @param update_inverse [Boolean] if true, updates the inverse association
|
173
|
+
# (if any). Defaults to false.
|
174
|
+
#
|
175
|
+
# @return [void]
|
176
|
+
def remove_value(entity, value, update_inverse: true) # rubocop:disable Lint/UnusedMethodArgument
|
177
|
+
raise AbstractAssociationError,
|
178
|
+
"#{self.class} is an abstract class - use an association subclass"
|
179
|
+
end
|
180
|
+
|
181
|
+
# @return [Stannum::Association] the inverse association, if any.
|
182
|
+
def resolved_inverse
|
183
|
+
return @resolved_inverse if @resolved_inverse
|
184
|
+
|
185
|
+
return unless inverse?
|
186
|
+
|
187
|
+
@resolved_inverse = resolved_type.associations[inverse_name]
|
188
|
+
rescue KeyError => exception
|
189
|
+
raise InverseAssociationError,
|
190
|
+
"unable to resolve inverse association #{exception.key.inspect}"
|
191
|
+
end
|
192
|
+
|
193
|
+
# @return [Module] the type of the association.
|
194
|
+
def resolved_type
|
195
|
+
return @resolved_type if @resolved_type
|
196
|
+
|
197
|
+
@resolved_type = Object.const_get(type)
|
198
|
+
|
199
|
+
unless @resolved_type.is_a?(Module)
|
200
|
+
raise NameError, "constant #{type} is not a Class or Module"
|
201
|
+
end
|
202
|
+
|
203
|
+
@resolved_type
|
204
|
+
end
|
205
|
+
|
206
|
+
# @api private
|
207
|
+
#
|
208
|
+
# Replaces the association for the entity with the given value.
|
209
|
+
#
|
210
|
+
# @param entity [Stannum::Entity] the entity to update.
|
211
|
+
# @param value [Object] the new value for the association.
|
212
|
+
# @param update_inverse [Boolean] if true, updates the inverse association
|
213
|
+
# (if any). Defaults to false.
|
214
|
+
#
|
215
|
+
# @return [void]
|
216
|
+
def set_value(entity, value, update_inverse: true) # rubocop:disable Lint/UnusedMethodArgument
|
217
|
+
raise AbstractAssociationError,
|
218
|
+
"#{self.class} is an abstract class - use an association subclass"
|
219
|
+
end
|
220
|
+
|
221
|
+
# @return [Symbol] the name of the writer method for the association.
|
222
|
+
def writer_name
|
223
|
+
@writer_name ||= :"#{name}="
|
224
|
+
end
|
225
|
+
|
226
|
+
private
|
227
|
+
|
228
|
+
def plural_class_name
|
229
|
+
@plural_class_name ||= tools.string_tools.pluralize(singular_class_name)
|
230
|
+
end
|
231
|
+
|
232
|
+
def resolve_inverse_name # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
233
|
+
return unless inverse?
|
234
|
+
|
235
|
+
return options[:inverse_name].to_s if options[:inverse_name]
|
236
|
+
|
237
|
+
if resolved_type.associations.key?(singular_class_name)
|
238
|
+
return singular_class_name
|
239
|
+
end
|
240
|
+
|
241
|
+
if resolved_type.associations.key?(plural_class_name)
|
242
|
+
return plural_class_name
|
243
|
+
end
|
244
|
+
|
245
|
+
raise InverseAssociationError,
|
246
|
+
'unable to resolve inverse association ' \
|
247
|
+
"#{singular_class_name.inspect} or #{plural_class_name.inspect}"
|
248
|
+
end
|
249
|
+
|
250
|
+
def resolve_type(type)
|
251
|
+
return [type, nil] if type.is_a?(String)
|
252
|
+
|
253
|
+
[type.to_s, type]
|
254
|
+
end
|
255
|
+
|
256
|
+
def singular_class_name
|
257
|
+
@singular_class_name ||=
|
258
|
+
entity_class_name
|
259
|
+
.split('::')
|
260
|
+
.last
|
261
|
+
.then { |str| tools.string_tools.underscore(str) }
|
262
|
+
end
|
263
|
+
|
264
|
+
def tools
|
265
|
+
SleepingKingStudios::Tools::Toolbelt.instance
|
266
|
+
end
|
267
|
+
|
268
|
+
def validate_name(name)
|
269
|
+
tools.assertions.validate_name(name, as: 'name')
|
270
|
+
end
|
271
|
+
|
272
|
+
def validate_options(options)
|
273
|
+
return if options.nil? || options.is_a?(Hash)
|
274
|
+
|
275
|
+
raise ArgumentError, 'options must be a Hash or nil'
|
276
|
+
end
|
277
|
+
|
278
|
+
def validate_type(type)
|
279
|
+
raise ArgumentError, "type can't be blank" if type.nil?
|
280
|
+
|
281
|
+
return if type.is_a?(Module)
|
282
|
+
|
283
|
+
if type.is_a?(String)
|
284
|
+
return unless type.empty?
|
285
|
+
|
286
|
+
raise ArgumentError, "type can't be blank"
|
287
|
+
end
|
288
|
+
|
289
|
+
raise ArgumentError,
|
290
|
+
'type must be a Class, a Module, or the name of a class or module'
|
291
|
+
end
|
292
|
+
end
|
293
|
+
end
|
@@ -0,0 +1,250 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
|
5
|
+
require 'stannum/associations'
|
6
|
+
|
7
|
+
module Stannum::Associations
|
8
|
+
# Data object representing a plural association.
|
9
|
+
class Many < Stannum::Association
|
10
|
+
# Wrapper object for an entity's plural association.
|
11
|
+
class Proxy
|
12
|
+
include Enumerable
|
13
|
+
extend Forwardable
|
14
|
+
|
15
|
+
# @param association [Stannum::Associations::Many] the association being
|
16
|
+
# wrapped.
|
17
|
+
# @param entity [Stannum::Entity] the entity instance whose association is
|
18
|
+
# being wrapped.
|
19
|
+
def initialize(association:, entity:)
|
20
|
+
@association = association
|
21
|
+
@entity = entity
|
22
|
+
end
|
23
|
+
|
24
|
+
# @!method [](index)
|
25
|
+
# Retrieves the value of the association at the specified index.
|
26
|
+
#
|
27
|
+
# @param index [Integer] the index of the value to retrieve.
|
28
|
+
#
|
29
|
+
# @return [Stannum::Entity] the association value at the index.
|
30
|
+
def_delegator :data, :[]
|
31
|
+
|
32
|
+
# @!method each(&block)
|
33
|
+
# @overload each
|
34
|
+
# @return [Enumerator] an enumerator over each item in the entity's
|
35
|
+
# association data.
|
36
|
+
#
|
37
|
+
# @overload each(&block)
|
38
|
+
# @yield Yields each item in the entity's association data.
|
39
|
+
# @yieldparam item [Stannum::Entity] the associated entity.
|
40
|
+
def_delegator :data, :each
|
41
|
+
|
42
|
+
# @!method empty?
|
43
|
+
# @return [true, false] true if the association has no items; otherwise
|
44
|
+
# false.
|
45
|
+
def_delegator :data, :empty?
|
46
|
+
|
47
|
+
# @!method size
|
48
|
+
# @return [Integer] the number of items in the association.
|
49
|
+
def_delegator :data, :size
|
50
|
+
alias size count
|
51
|
+
|
52
|
+
# @param other [Object] the object to compare.
|
53
|
+
#
|
54
|
+
# @return [true, false] true if the object has matching data; otherwise
|
55
|
+
# false.
|
56
|
+
def ==(other)
|
57
|
+
return false if other.nil?
|
58
|
+
|
59
|
+
return false unless other.respond_to?(:to_a)
|
60
|
+
|
61
|
+
to_a == other.to_a
|
62
|
+
end
|
63
|
+
|
64
|
+
# Appends the entity to the association.
|
65
|
+
#
|
66
|
+
# If the entity is already in the association data, this method does
|
67
|
+
# nothing.
|
68
|
+
#
|
69
|
+
# @param value [Stannum::Entity] the entity to add.
|
70
|
+
#
|
71
|
+
# @return [self] the association proxy.
|
72
|
+
def add(value)
|
73
|
+
unless value.is_a?(association.resolved_type)
|
74
|
+
message =
|
75
|
+
'invalid association item - must be an instance of ' \
|
76
|
+
"#{association.resolved_type.name}"
|
77
|
+
|
78
|
+
raise ArgumentError, message
|
79
|
+
end
|
80
|
+
|
81
|
+
association.add_value(entity, value)
|
82
|
+
|
83
|
+
self
|
84
|
+
end
|
85
|
+
alias << add
|
86
|
+
alias push add
|
87
|
+
|
88
|
+
# @return [String] a human-readable string representation of the object.
|
89
|
+
def inspect
|
90
|
+
"#{super[...55]} data=[#{each.map(&:inspect).join(', ')}]>"
|
91
|
+
end
|
92
|
+
|
93
|
+
# Removes the entity from the association.
|
94
|
+
#
|
95
|
+
# If the entity is not in the association data, this method does nothing.
|
96
|
+
#
|
97
|
+
# @param value [Stannum::Entity] the entity to remove.
|
98
|
+
#
|
99
|
+
# @return [self] the association proxy.
|
100
|
+
def remove(value)
|
101
|
+
unless value.is_a?(association.resolved_type)
|
102
|
+
message =
|
103
|
+
'invalid association item - must be an instance of ' \
|
104
|
+
"#{association.resolved_type.name}"
|
105
|
+
|
106
|
+
raise ArgumentError, message
|
107
|
+
end
|
108
|
+
|
109
|
+
association.remove_value(entity, value)
|
110
|
+
|
111
|
+
self
|
112
|
+
end
|
113
|
+
alias delete remove
|
114
|
+
|
115
|
+
private
|
116
|
+
|
117
|
+
attr_reader :association
|
118
|
+
|
119
|
+
attr_reader :entity
|
120
|
+
|
121
|
+
def data
|
122
|
+
entity.read_association(association.name, safe: false) || []
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
# (see Stannum::Association#add_value)
|
127
|
+
def add_value(entity, value, update_inverse: true)
|
128
|
+
return unless value
|
129
|
+
|
130
|
+
data = entity.read_association(name, safe: false) || []
|
131
|
+
|
132
|
+
data, changed = add_item(data:, value:)
|
133
|
+
|
134
|
+
entity.write_association(name, data, safe: false) if changed
|
135
|
+
|
136
|
+
update_item_inverse(entity:, value:) if inverse? && update_inverse
|
137
|
+
|
138
|
+
nil
|
139
|
+
end
|
140
|
+
|
141
|
+
# (see Stannum::Association#clear_value)
|
142
|
+
def clear_value(entity, update_inverse: true)
|
143
|
+
data = entity.read_association(name, safe: false) || []
|
144
|
+
|
145
|
+
entity.write_association(name, [], safe: false)
|
146
|
+
|
147
|
+
if inverse? && update_inverse
|
148
|
+
data.each { |item| remove_item_inverse(value: item) }
|
149
|
+
end
|
150
|
+
|
151
|
+
nil
|
152
|
+
end
|
153
|
+
|
154
|
+
# (see Stannum::Association#get_value)
|
155
|
+
def get_value(entity)
|
156
|
+
entity.association_proxy_for(self)
|
157
|
+
end
|
158
|
+
|
159
|
+
# @return [true] true if the association is a plural association; otherwise
|
160
|
+
# false.
|
161
|
+
def many?
|
162
|
+
true
|
163
|
+
end
|
164
|
+
|
165
|
+
# (see Stannum::Association#remove_value)
|
166
|
+
def remove_value(entity, value, update_inverse: true)
|
167
|
+
return unless value
|
168
|
+
|
169
|
+
data = entity.read_association(name, safe: false) || []
|
170
|
+
|
171
|
+
data, changed = remove_item(data:, value:)
|
172
|
+
|
173
|
+
return unless changed
|
174
|
+
|
175
|
+
entity.write_association(name, data, safe: false)
|
176
|
+
|
177
|
+
remove_item_inverse(value:) if inverse? && update_inverse
|
178
|
+
|
179
|
+
nil
|
180
|
+
end
|
181
|
+
|
182
|
+
# (see Stannum::Association#resolved_inverse)
|
183
|
+
def resolved_inverse
|
184
|
+
return @resolved_inverse if @resolved_inverse
|
185
|
+
|
186
|
+
inverse = super
|
187
|
+
|
188
|
+
return inverse unless inverse&.many?
|
189
|
+
|
190
|
+
raise InverseAssociationError,
|
191
|
+
"invalid inverse association #{inverse_name.inspect} - :many to " \
|
192
|
+
':many associations are not currently supported'
|
193
|
+
end
|
194
|
+
|
195
|
+
# (see Stannum::Association#set_value)
|
196
|
+
def set_value(entity, value, update_inverse: true)
|
197
|
+
data = entity.read_association(name, safe: false) || []
|
198
|
+
|
199
|
+
value&.each do |item|
|
200
|
+
next unless item
|
201
|
+
|
202
|
+
data, _ = add_item(data:, value: item)
|
203
|
+
|
204
|
+
update_item_inverse(entity:, value: item) if inverse? && update_inverse
|
205
|
+
end
|
206
|
+
|
207
|
+
entity.write_association(name, data, safe: false)
|
208
|
+
|
209
|
+
nil
|
210
|
+
end
|
211
|
+
|
212
|
+
private
|
213
|
+
|
214
|
+
def add_item(data:, value:)
|
215
|
+
return data, false if data.include?(value)
|
216
|
+
|
217
|
+
data = [*data, value]
|
218
|
+
|
219
|
+
[data, true]
|
220
|
+
end
|
221
|
+
|
222
|
+
def remove_item(data:, value:)
|
223
|
+
return data, false unless data.include?(value)
|
224
|
+
|
225
|
+
data = data.reject { |item| item == value }
|
226
|
+
|
227
|
+
[data, true]
|
228
|
+
end
|
229
|
+
|
230
|
+
def remove_item_inverse(value:, inverse_value: nil, update_inverse: false)
|
231
|
+
inverse_value ||= resolved_inverse.get_value(value)
|
232
|
+
|
233
|
+
return unless inverse_value
|
234
|
+
|
235
|
+
resolved_inverse.remove_value(value, inverse_value, update_inverse:)
|
236
|
+
end
|
237
|
+
|
238
|
+
def update_item_inverse(entity:, value:)
|
239
|
+
inverse_value = resolved_inverse.get_value(value)
|
240
|
+
|
241
|
+
return if entity == inverse_value
|
242
|
+
|
243
|
+
if inverse_value
|
244
|
+
remove_item_inverse(inverse_value:, value:, update_inverse: true)
|
245
|
+
end
|
246
|
+
|
247
|
+
resolved_inverse.add_value(value, entity, update_inverse: false)
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'stannum/associations'
|
4
|
+
|
5
|
+
module Stannum::Associations
|
6
|
+
# Data object representing a singular association.
|
7
|
+
class One < Stannum::Association
|
8
|
+
# (see Stannum::Association#add_value)
|
9
|
+
def add_value(entity, value, update_inverse: true)
|
10
|
+
set_value(entity, value, update_inverse:)
|
11
|
+
end
|
12
|
+
|
13
|
+
# (see Stannum::Association#clear_value)
|
14
|
+
def clear_value(entity, update_inverse: true)
|
15
|
+
previous_value = entity.read_association(name, safe: false)
|
16
|
+
|
17
|
+
if update_inverse && previous_value && inverse?
|
18
|
+
resolved_inverse.remove_value(
|
19
|
+
previous_value,
|
20
|
+
entity,
|
21
|
+
update_inverse: false
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
entity.write_attribute(foreign_key_name, nil, safe: false) if foreign_key?
|
26
|
+
|
27
|
+
entity.write_association(name, nil, safe: false)
|
28
|
+
end
|
29
|
+
|
30
|
+
# @return [Boolean] true if the association has a foreign key; otherwise
|
31
|
+
# false.
|
32
|
+
def foreign_key?
|
33
|
+
return @has_foreign_key unless @has_foreign_key.nil?
|
34
|
+
|
35
|
+
value = options[:foreign_key_name]
|
36
|
+
|
37
|
+
return @has_foreign_key = false if value.nil? || value == false
|
38
|
+
|
39
|
+
@has_foreign_key = true
|
40
|
+
end
|
41
|
+
|
42
|
+
# @return [String?] the name of the foreign key, if any.
|
43
|
+
def foreign_key_name
|
44
|
+
return nil unless foreign_key?
|
45
|
+
|
46
|
+
@foreign_key_name ||= options[:foreign_key_name].to_s
|
47
|
+
end
|
48
|
+
|
49
|
+
# @return [Class, Stannum::Constraint, nil] the type of the foreign key, if
|
50
|
+
# any.
|
51
|
+
def foreign_key_type
|
52
|
+
return nil unless foreign_key?
|
53
|
+
|
54
|
+
@foreign_key_type ||= options[:foreign_key_type]
|
55
|
+
end
|
56
|
+
|
57
|
+
# (see Stannum::Association#get_value)
|
58
|
+
def get_value(entity)
|
59
|
+
entity.read_association(name, safe: false)
|
60
|
+
end
|
61
|
+
|
62
|
+
# @return [true] true if the association is a singular association;
|
63
|
+
# otherwise false.
|
64
|
+
def one?
|
65
|
+
true
|
66
|
+
end
|
67
|
+
|
68
|
+
# (see Stannum::Association#remove_value)
|
69
|
+
def remove_value(entity, value, update_inverse: true)
|
70
|
+
previous_value = entity.read_association(name, safe: false)
|
71
|
+
|
72
|
+
return unless matching_value?(value, previous_value)
|
73
|
+
|
74
|
+
clear_value(entity, update_inverse:)
|
75
|
+
end
|
76
|
+
|
77
|
+
# (see Stannum::Association#set_value)
|
78
|
+
def set_value(entity, value, update_inverse: true) # rubocop:disable Metrics/MethodLength
|
79
|
+
if foreign_key?
|
80
|
+
entity.write_attribute(
|
81
|
+
foreign_key_name,
|
82
|
+
value&.primary_key,
|
83
|
+
safe: false
|
84
|
+
)
|
85
|
+
end
|
86
|
+
|
87
|
+
entity.write_association(name, value, safe: false)
|
88
|
+
|
89
|
+
return unless update_inverse && value && inverse?
|
90
|
+
|
91
|
+
previous_inverse = resolved_inverse.get_value(value)
|
92
|
+
|
93
|
+
resolved_inverse.remove_value(value, previous_inverse) if previous_inverse
|
94
|
+
|
95
|
+
resolved_inverse.add_value(value, entity, update_inverse: false)
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def matching_value?(value, previous_value)
|
101
|
+
return true if value == previous_value
|
102
|
+
|
103
|
+
foreign_key? && (value == previous_value&.primary_key)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'stannum'
|
4
|
+
|
5
|
+
module Stannum
|
6
|
+
# Namespace for modules implementing Association functionality.
|
7
|
+
module Associations
|
8
|
+
autoload :Many, 'stannum/associations/many'
|
9
|
+
autoload :One, 'stannum/associations/one'
|
10
|
+
end
|
11
|
+
end
|