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.
- checksums.yaml +7 -0
- data/LICENSE +7 -0
- data/README.md +99 -0
- data/USAGE.md +390 -0
- data/lib/minitwin/assignment.rb +95 -0
- data/lib/minitwin/class_methods/caches.rb +94 -0
- data/lib/minitwin/class_methods/coercion.rb +114 -0
- data/lib/minitwin/class_methods/constructors.rb +120 -0
- data/lib/minitwin/class_methods/dsl.rb +404 -0
- data/lib/minitwin/class_methods/rbs.rb +129 -0
- data/lib/minitwin/class_methods/types_helper.rb +70 -0
- data/lib/minitwin/class_methods.rb +26 -0
- data/lib/minitwin/initialization.rb +188 -0
- data/lib/minitwin/railtie.rb +9 -0
- data/lib/minitwin/serialization.rb +172 -0
- data/lib/minitwin/sync.rb +137 -0
- data/lib/minitwin/version.rb +5 -0
- data/lib/minitwin.rb +129 -0
- data/lib/tasks/minitwin.rake +70 -0
- data/sig/generated/minitwin/assignment.rbs +29 -0
- data/sig/generated/minitwin/class_methods/caches.rbs +40 -0
- data/sig/generated/minitwin/class_methods/constructors.rbs +41 -0
- data/sig/generated/minitwin/class_methods/dsl.rbs +79 -0
- data/sig/generated/minitwin/class_methods/rbs.rbs +20 -0
- data/sig/generated/minitwin/initialization.rbs +45 -0
- data/sig/generated/minitwin/serialization.rbs +47 -0
- data/sig/generated/minitwin/sync.rbs +19 -0
- data/sig/module.rbs +19 -0
- metadata +89 -0
|
@@ -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
|