minitwin 1.0.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.
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Minitwin
4
+ module ClassMethods
5
+ module Coercion
6
+ private
7
+
8
+ # Iterate over all attribute sources (properties, collections, and allowed keys)
9
+ # for a target class, yielding each key to the block
10
+ def iterate_attribute_sources(target_klass, &block)
11
+ return unless target_klass
12
+ return unless target_klass.respond_to?(:allowed_attribute_keys, true)
13
+
14
+ target_klass.send(:allowed_attribute_keys).each(&block)
15
+ end
16
+
17
+ def coerce_value_to_twin(value, target_klass)
18
+ return nil if value.nil?
19
+ return value unless target_klass
20
+ return value if value.is_a?(target_klass)
21
+
22
+ # Check array pair format before to_h (arrays respond to :to_h in Ruby 3+)
23
+ return coerce_from_array_pair(value, target_klass) if array_pair_format?(value)
24
+ # Try standard conversion methods
25
+ return coerce_from_to_h(value, target_klass) if value.respond_to?(:to_h) && !value.is_a?(Array)
26
+ return coerce_from_attributes(value, target_klass) if value.respond_to?(:attributes)
27
+ return target_klass.new(**value) if value.is_a?(Hash)
28
+
29
+ # Fallback: reflect by reading known properties or instance variables
30
+ coerce_by_reflection(value, target_klass)
31
+ end
32
+
33
+ def coerce_from_to_h(value, target_klass)
34
+ attrs = value.to_h
35
+ enrich_attrs_from_readers!(attrs, value, target_klass)
36
+ target_klass.new(**attrs)
37
+ end
38
+
39
+ def coerce_from_attributes(value, target_klass)
40
+ attrs = value.attributes
41
+ enrich_attrs_from_readers!(attrs, value, target_klass)
42
+ target_klass.new(**attrs)
43
+ end
44
+
45
+ def array_pair_format?(value)
46
+ value.is_a?(Array) && value.size == 2 && value.last.is_a?(Hash)
47
+ end
48
+
49
+ def coerce_from_array_pair(value, target_klass)
50
+ target_klass.new(**value.last)
51
+ end
52
+
53
+ def coerce_by_reflection(value, target_klass)
54
+ attrs = extract_attrs_by_reflection(value, target_klass)
55
+ attrs = extract_instance_variables(value) if attrs.empty?
56
+ attrs.empty? ? value : target_klass.new(**attrs)
57
+ end
58
+
59
+ def extract_attrs_by_reflection(value, target_klass)
60
+ attrs = {}
61
+ begin
62
+ iterate_attribute_sources(target_klass) do |key|
63
+ next if attrs.key?(key)
64
+
65
+ attrs[key] = value.public_send(key) if value.respond_to?(key)
66
+ end
67
+ rescue StandardError
68
+ # Expected: Reflection may fail if source object raises in attribute readers
69
+ # or has unexpected behavior. Continue with partial attributes.
70
+ end
71
+ attrs
72
+ end
73
+
74
+ def extract_instance_variables(value)
75
+ return {} unless value.respond_to?(:instance_variables) && value.instance_variables.any?
76
+
77
+ value.instance_variables.to_h do |var|
78
+ [Minitwin::Utils.ivar_to_key(var), value.instance_variable_get(var)]
79
+ end
80
+ end
81
+
82
+ def enrich_attrs_from_readers!(attrs, source, target_klass)
83
+ return attrs unless target_klass
84
+
85
+ begin
86
+ # Enrich from properties and allowed attribute keys
87
+ iterate_attribute_sources(target_klass) do |key|
88
+ next if attrs.key?(key) && !attrs[key].nil?
89
+ next unless source.respond_to?(key)
90
+
91
+ # Special handling for collections to ensure array coercion
92
+ attrs[key] = if target_klass.respond_to?(:collections) && target_klass.collections.key?(key)
93
+ coerce_collection_array(source.public_send(key))
94
+ else
95
+ source.public_send(key)
96
+ end
97
+ end
98
+ rescue StandardError
99
+ # Expected: Source object may raise in attribute readers or have
100
+ # unexpected behavior. Be resilient and proceed with partial attributes.
101
+ end
102
+
103
+ attrs
104
+ end
105
+
106
+ def coerce_collection_array(raw)
107
+ return raw if raw.is_a?(Array)
108
+ return raw.to_a if raw.respond_to?(:to_a)
109
+
110
+ Array(raw)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class Minitwin
5
+ module ClassMethods
6
+ module Constructors
7
+
8
+ #: () -> Hash[untyped, untyped]
9
+ def properties
10
+ @properties ||= {}
11
+ end
12
+
13
+ #: () -> Hash[untyped, untyped]
14
+ def collections
15
+ @collections ||= {}
16
+ end
17
+
18
+ #: (Hash[untyped, untyped] args) -> instance
19
+ def from_hash(args)
20
+ new(**args)
21
+ end
22
+
23
+ #: (String body) -> instance
24
+ def from_json(body)
25
+ hash = JSON.parse(body, symbolize_names: true)
26
+ from_hash(hash)
27
+ end
28
+
29
+ # Actually, this is expected to be an `ActionController::Parameters`
30
+ # object. The type will be unknown when used without rails. So for RBS
31
+ # the argument is typed `untyped`.
32
+ #: (untyped params) -> instance
33
+ def from_params(params)
34
+ return from_hash(params) unless params.respond_to?(:to_unsafe_h)
35
+
36
+ from_hash params.to_unsafe_h
37
+ end
38
+
39
+ #: (untyped model) -> instance
40
+ def from_object(model)
41
+ if model.is_a?(Hash)
42
+ raise(
43
+ "Input is not an object. If you want to instantiate a Minitwin with multiple " \
44
+ "objects, then use the pluralized 'from_objects'-method."
45
+ )
46
+ end
47
+
48
+ from_objects(model:)
49
+ end
50
+
51
+ #: (Hash[Symbol, untyped] **models) -> instance
52
+ def from_objects(**models)
53
+ attributes =
54
+ models.values.map do |model|
55
+ if model.respond_to?(:attributes) && model.respond_to?(:attribute_aliases)
56
+ combined_attributes = model.attributes.dup
57
+ model.attribute_aliases.each do |alias_name, real_attr|
58
+ combined_attributes[alias_name] = model.send(real_attr)
59
+ end
60
+ combined_attributes
61
+ elsif model.respond_to?(:to_h)
62
+ model.to_h
63
+ elsif model.respond_to?(:attributes)
64
+ model.attributes
65
+ else
66
+ extract_instance_variables(model)
67
+ end
68
+ end.reduce({}, :merge)
69
+
70
+ # Enrich attributes with relation-style readers (e.g., has_one/has_many)
71
+ # when they are not present in the model's attributes hash. This allows
72
+ # nested block/twin properties and collections to be populated from
73
+ # object readers commonly used by ORMs like ActiveRecord.
74
+ enrich_attributes_from_models!(attributes, models)
75
+
76
+ obj = new(**attributes)
77
+ models.each { |name, model| obj.instance_variable_set(internal_model_name(name), model) }
78
+ obj
79
+ end
80
+
81
+ #: (Array[untyped] models) -> Array[instance]
82
+ def from_collection(models)
83
+ models.map { |item| from_objects(model: item) }
84
+ end
85
+
86
+ #: (Symbol name) -> String | nil
87
+ def internal_model_name(name)
88
+ "#{Minitwin::INTERNAL_MODEL_PREFIX}#{name}" unless name.nil?
89
+ end
90
+
91
+ def enrich_attributes_from_models!(attributes, models)
92
+ properties.each_key do |key|
93
+ enrich_attribute_from_models(attributes, models, key, is_collection: false)
94
+ end
95
+
96
+ collections.each_key do |key|
97
+ enrich_attribute_from_models(attributes, models, key, is_collection: true)
98
+ end
99
+ rescue StandardError
100
+ # Expected: Model objects may raise in attribute readers or have unexpected
101
+ # behavior. Be resilient and proceed with best-effort enrichment.
102
+ end
103
+
104
+ def enrich_attribute_from_models(attributes, models, key, is_collection:)
105
+ # Only enrich when attribute is missing or nil
106
+ return if attributes.key?(key) && !attributes[key].nil?
107
+
108
+ models.each_value do |model|
109
+ next unless model.respond_to?(key)
110
+
111
+ val = model.public_send(key)
112
+ next if val.nil?
113
+
114
+ attributes[key] = is_collection ? coerce_collection_array(val) : val
115
+ break
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,404 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class Minitwin
5
+ module ClassMethods
6
+ module Dsl
7
+
8
+ #: () -> Array[Symbol]
9
+ def block_properties
10
+ @block_properties ||= []
11
+ end
12
+
13
+ #: () -> Array[Symbol]
14
+ def collection_properties
15
+ @collection_properties ||= []
16
+ end
17
+
18
+ #: () -> Array[Symbol]
19
+ def unexposed_properties
20
+ @unexposed_properties ||= []
21
+ end
22
+
23
+ #: () -> Array[Symbol]
24
+ def property_order
25
+ @property_order ||= []
26
+ end
27
+
28
+ #: () -> Array[Hash]
29
+ def dynamic_nested_aliases
30
+ @dynamic_nested_aliases ||= []
31
+ end
32
+
33
+ # @rbs name: Symbol
34
+ # @rbs validates: Hash[Symbol, untyped]
35
+ # @rbs default: untyped
36
+ # @rbs as: Symbol | Proc
37
+ # @rbs getter: Proc
38
+ # @rbs twin: untyped
39
+ # @rbs on: Symbol
40
+ # @rbs return: void
41
+ def collection(name, validates: {}, default: [], as: nil, getter: nil, twin: nil, on: nil, **_opts, &block)
42
+ nested_class = block ? create_nested_class(name:, &block) : nil
43
+ element_klass = twin || nested_class
44
+
45
+ define_method("#{name}=") do |values|
46
+ arr = self.class.send(:coerce_collection_array, values)
47
+ coerced_values = arr.map { |v| self.class.send(:coerce_value_to_twin, v, element_klass) }
48
+ define_instance_variable(name:, value: coerced_values)
49
+ # :nocov:
50
+ if !@__skip_alias_recompute__ && self.class.dynamic_aliases?
51
+ __recompute_dynamic_aliases__
52
+ end
53
+ # :nocov:
54
+ end
55
+ alias_method "#{name}_attributes=", "#{name}="
56
+
57
+ define_getter_method(name:, on:, as:, default:, getter:, type: nil)
58
+ alias_method "#{name}_attributes", name
59
+ add_validation(name:, validates:)
60
+ add_collection_property(name:)
61
+ invalidate_caches
62
+
63
+ collections[name.to_sym] = { element_twin: element_klass, as: as }
64
+ add_to_property_order(name)
65
+ end
66
+
67
+ # @rbs name: Symbol
68
+ # @rbs validates: Hash[Symbol, untyped]
69
+ # @rbs default: untyped
70
+ # @rbs as: Symbol | Proc
71
+ # @rbs expose: bool
72
+ # @rbs readonly: bool
73
+ # @rbs type: untyped
74
+ # @rbs getter: Proc
75
+ # @rbs setter: Proc
76
+ # @rbs twin: untyped
77
+ # @rbs on: Symbol
78
+ # @rbs return: void
79
+ def property(
80
+ name, validates: {}, default: nil, as: nil, expose: true, readonly: false, type: nil, getter: nil, setter: nil,
81
+ twin: nil, on: nil, **_opts, &block
82
+ )
83
+ nested_class = nil
84
+
85
+ if block_given?
86
+ raise "setters are not possible in blocks" if setter
87
+
88
+ nested_class = create_nested_class(name:, &block)
89
+
90
+ define_method("#{name}=") do |value|
91
+ coerced = self.class.send(:coerce_value_to_twin, value, nested_class)
92
+ raise "Unprocessable input for property '#{name}'." unless coerced.nil? || coerced.is_a?(nested_class)
93
+
94
+ define_instance_variable(name:, value: coerced)
95
+ if !@__skip_alias_recompute__ && self.class.dynamic_aliases?
96
+ __recompute_dynamic_aliases__
97
+ end
98
+ end
99
+
100
+ add_block_property(name:)
101
+ else
102
+ define_method("#{name}=") do |value|
103
+ coerced_value =
104
+ if twin
105
+ self.class.send(:coerce_value_to_twin, value, twin)
106
+ elsif setter
107
+ setter.call(value)
108
+ elsif type && !value.nil?
109
+ self.class.send(:coerce_with_type, value, type)
110
+ else
111
+ value
112
+ end
113
+ define_instance_variable(name:, value: coerced_value)
114
+ if !@__skip_alias_recompute__ && self.class.dynamic_aliases?
115
+ __recompute_dynamic_aliases__
116
+ end
117
+ end
118
+ end
119
+
120
+ define_getter_method(name:, as:, on:, default:, getter:, type: type)
121
+ add_validation(name:, validates:)
122
+ add_unexposed_property(name:, expose:)
123
+ invalidate_caches
124
+
125
+ properties[name.to_sym] = {
126
+ type: type,
127
+ as: as,
128
+ expose: expose,
129
+ readonly: readonly
130
+ }
131
+ properties[name.to_sym][:twin] = twin if twin
132
+ properties[name.to_sym][:nested_class] = nested_class if nested_class
133
+ add_to_property_order(name)
134
+ end
135
+
136
+ # @rbs name: Symbol
137
+ # @rbs as: Symbol | Proc
138
+ # @rbs return: void
139
+ def nested(name, as: nil, &block)
140
+ raise ArgumentError, "nested requires a block" unless block_given?
141
+
142
+ property(name, as: as, &block)
143
+
144
+ # Pull the nested class directly from the registration `property`
145
+ # just performed instead of round-tripping through `const_get`.
146
+ nested_klass = properties[name.to_sym]&.[](:nested_class)
147
+
148
+ # Registry for dynamic nested aliases (as: -> { ... }) on leafs.
149
+ # Reader defined once in the module body above.
150
+ leafs = []
151
+ if nested_klass.respond_to?(:properties)
152
+ extract_leaf_properties = ->(klass, path) do
153
+ klass.properties.each do |prop, meta|
154
+ if meta[:nested_class]
155
+ extract_leaf_properties.call(meta[:nested_class], path + [prop])
156
+ else
157
+ leafs << { path: (path + [prop]), as: (meta[:as] if meta[:as] && meta[:as] != prop) }
158
+ end
159
+ end
160
+ end
161
+ extract_leaf_properties.call(nested_klass, [])
162
+ end
163
+
164
+ leafs.each do |leaf| # rubocop: disable Metrics/BlockLength
165
+ path = leaf[:path]
166
+ prop = path.last
167
+ as_meta = leaf[:as]
168
+
169
+ # Define a stable internal reader for this leaf to support dynamic aliasing
170
+ target_reader = "#{Minitwin::NESTED_READER_PREFIX}#{([name] + path).join("__")}"
171
+ define_method(target_reader) do
172
+ obj = send(name)
173
+ obj = Minitwin::Utils.traverse_path(obj, path[0..-2])
174
+ if as_meta.is_a?(Proc)
175
+ # When inner property has a dynamic alias, original reader may be protected.
176
+ obj.send(prop)
177
+ else
178
+ inner_read = as_meta || prop
179
+ # Use send to allow accessing protected original readers
180
+ obj.send(inner_read)
181
+ end
182
+ end
183
+
184
+ # Setter uses original base name to call the nested twin's writer.
185
+ define_method("#{prop}=") do |value|
186
+ obj = send(name)
187
+ obj = Minitwin::Utils.traverse_path(obj, path[0..-2])
188
+ obj.public_send("#{prop}=", value)
189
+ if !@__skip_alias_recompute__ && self.class.dynamic_aliases?
190
+ __recompute_dynamic_aliases__
191
+ end
192
+ end
193
+
194
+ # Static alias: define a public getter method with the alias name
195
+ if as_meta && !as_meta.is_a?(Proc)
196
+ alias_name = as_meta
197
+ define_method(alias_name) do
198
+ send(target_reader)
199
+ end
200
+ unexposed_properties << alias_name
201
+
202
+ # Define protected getter with original name so sync can read the value,
203
+ # and register in properties so sync resolves the as: alias.
204
+ define_method(prop) { send(target_reader) }
205
+ protected prop
206
+ properties[prop.to_sym] = { type: nil, as: as_meta, expose: true, nested_proxy: true }
207
+ else
208
+ # Dynamic alias: register for instance-level aliasing and rely on
209
+ # __recompute_dynamic_aliases__ to create the per-instance method.
210
+ dynamic_nested_aliases << { target: target_reader.to_sym, as: as_meta || prop, group: name, path: path }
211
+ end
212
+
213
+ # Always hide the internal reader from serialization
214
+ unexposed_properties << target_reader.to_sym
215
+ end
216
+
217
+ invalidate_caches
218
+ end
219
+
220
+ private
221
+
222
+ def constantize_name(name)
223
+ name.to_s.split("_").map(&:capitalize).join
224
+ end
225
+
226
+ def create_nested_class(name:, &block)
227
+ Class.new(Minitwin).tap do |klass|
228
+ if defined?(ActiveModel::Name)
229
+ klass.define_singleton_method(:model_name) do
230
+ ActiveModel::Name.new(self, nil, name.to_s)
231
+ end
232
+ end
233
+
234
+ klass.class_eval(&block) if block
235
+
236
+ const_name = constantize_name(name)
237
+ begin
238
+ const_set(const_name, klass) unless const_defined?(const_name, false)
239
+ rescue NameError
240
+ # Expected: Constant name may be invalid or already defined in complex scenarios.
241
+ # The nested class is still accessible via the klass variable.
242
+ end
243
+ end
244
+ end
245
+
246
+ def define_getter_method(name:, as:, on:, default:, getter:, type: nil)
247
+ getter_proc = build_getter_proc(name:, on:, default:, getter:, type:)
248
+ define_method(name, &getter_proc)
249
+ apply_alias_to_getter(name:, as:)
250
+ end
251
+
252
+ def build_getter_proc(name:, on:, default:, getter:, type:)
253
+ if getter
254
+ ivar = Minitwin::Utils.ivar_name(name)
255
+ if getter.is_a?(Symbol)
256
+ return -> {
257
+ if self.class.instance_method(getter).arity.zero?
258
+ send(getter)
259
+ else
260
+ send(getter, instance_variable_get(ivar))
261
+ end
262
+ }
263
+ elsif getter.arity.zero?
264
+ return -> { instance_exec(&getter) }
265
+ else
266
+ return -> { instance_exec(instance_variable_get(ivar), &getter) }
267
+ end
268
+ end
269
+
270
+ if on
271
+ build_composition_getter(name:, on:, default:, type:)
272
+ else
273
+ build_regular_getter(name:, default:, type:)
274
+ end
275
+ end
276
+
277
+ def build_composition_getter(name:, on:, default:, type:)
278
+ # Resolve model ivar name at definition time when on is a symbol
279
+ model_ivar = on.is_a?(Proc) ? nil : internal_model_name(on)
280
+ # Collection metadata will be resolved after property registration
281
+ # via a lazy lookup on first access, then cached in the closure.
282
+ col_meta = nil
283
+ col_meta_resolved = false
284
+
285
+ -> { # rubocop: disable Metrics/BlockLength
286
+ # Get composition model - handle both symbol and proc cases
287
+ model = if on.is_a?(Proc)
288
+ instance_exec(&on)
289
+ else
290
+ instance_variable_get(model_ivar) || begin
291
+ send(on)
292
+ rescue NoMethodError
293
+ nil
294
+ end
295
+ end
296
+
297
+ # Validate model
298
+ if model.nil?
299
+ raise(
300
+ "Property '#{name}' refers to unknown composition source '#{on}' in #{self.class}. " \
301
+ "Ensure the model is provided via from_objects or a reader exists."
302
+ )
303
+ end
304
+ unless model.respond_to?(name)
305
+ raise "The instance of '#{model.class}' does not respond to '#{name}'."
306
+ end
307
+
308
+ raw = model.send(name)
309
+
310
+ # Return default if raw is nil
311
+ return self.class.send(:resolve_default_value, default, type) if raw.nil?
312
+
313
+ # Resolve collection metadata once and cache in closure
314
+ unless col_meta_resolved
315
+ col_meta = begin
316
+ self.class.collections[name.to_sym]
317
+ rescue StandardError
318
+ nil
319
+ end
320
+ col_meta_resolved = true
321
+ end
322
+
323
+ if col_meta && (raw.is_a?(Array) || raw.respond_to?(:to_a))
324
+ elem_klass = col_meta[:element_twin]
325
+ arr = self.class.send(:coerce_collection_array, raw)
326
+ arr.map { |v| self.class.send(:coerce_value_to_twin, v, elem_klass) }
327
+ else
328
+ type ? self.class.send(:coerce_with_type, raw, type) : raw
329
+ end
330
+ }
331
+ end
332
+
333
+ def build_regular_getter(name:, default:, type:)
334
+ # Compute ivar_name at definition time for JIT optimization.
335
+ # Type coercion happens on assignment (setter) so the getter just reads.
336
+ ivar = Minitwin::Utils.ivar_name(name)
337
+ -> {
338
+ if instance_variable_defined?(ivar)
339
+ val = instance_variable_get(ivar)
340
+ return self.class.send(:resolve_default_value, default, type) if val.nil?
341
+
342
+ val
343
+ else
344
+ self.class.send(:resolve_default_value, default, type)
345
+ end
346
+ }
347
+ end
348
+
349
+ def apply_alias_to_getter(name:, as:)
350
+ return if as.nil?
351
+
352
+ if as.is_a?(Proc)
353
+ # Dynamic alias: protect original reader and let instances
354
+ # compute and define the alias method at runtime.
355
+ protected name
356
+ elsif name != as
357
+ alias_method as, name
358
+ protected name
359
+ end
360
+ end
361
+
362
+ def add_validation(name:, validates:)
363
+ return if validates.nil?
364
+ return if validates.respond_to?(:empty?) && validates.empty?
365
+
366
+ raise "Validation is not possible, because activemodel is not available" unless respond_to?(:validates)
367
+
368
+ if validates.is_a?(Proc)
369
+ validate do
370
+ value = send(name)
371
+ errors.add(name, "is invalid") unless validates.call(value)
372
+ end
373
+ else
374
+ validates(name, **validates)
375
+ end
376
+ end
377
+
378
+ def add_unexposed_property(name:, expose:)
379
+ unexposed_properties << name unless expose
380
+ end
381
+
382
+ def add_block_property(name:)
383
+ block_properties << name.to_sym
384
+ end
385
+
386
+ def add_collection_property(name:)
387
+ collection_properties << name.to_sym
388
+ end
389
+
390
+ def add_to_property_order(name)
391
+ key = name.to_sym
392
+ property_order << key unless property_order.include?(key)
393
+ end
394
+
395
+ def resolve_default_value(default, type)
396
+ unless default.nil?
397
+ return default.respond_to?(:call) ? default.call : default
398
+ end
399
+
400
+ type ? type_default_value(type) : nil
401
+ end
402
+ end
403
+ end
404
+ end