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,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class Minitwin
|
|
5
|
+
module ClassMethods
|
|
6
|
+
module Rbs
|
|
7
|
+
|
|
8
|
+
#: () -> String
|
|
9
|
+
def to_rbs
|
|
10
|
+
# :nocov:
|
|
11
|
+
return "" unless name
|
|
12
|
+
|
|
13
|
+
lines = []
|
|
14
|
+
superclass_name = superclass ? " < ::#{superclass.name}" : ""
|
|
15
|
+
# :nocov:
|
|
16
|
+
lines << "class ::#{name}#{superclass_name}"
|
|
17
|
+
|
|
18
|
+
# Collect initializer parameters
|
|
19
|
+
init_params = []
|
|
20
|
+
|
|
21
|
+
props = properties
|
|
22
|
+
props.each do |prop, meta|
|
|
23
|
+
as_meta = meta[:as]
|
|
24
|
+
# Dynamic aliases (Proc) cannot be represented statically in RBS;
|
|
25
|
+
# fall back to the base property name.
|
|
26
|
+
reader_name = as_meta && !as_meta.is_a?(Proc) && as_meta != prop ? as_meta : prop
|
|
27
|
+
type = rbs_type_for(meta)
|
|
28
|
+
lines << " attr_reader #{reader_name}: #{type}"
|
|
29
|
+
if method_defined?(:"#{prop}=", false)
|
|
30
|
+
# :nocov:
|
|
31
|
+
lines << " attr_writer #{prop}: #{type}"
|
|
32
|
+
# :nocov:
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Add to initializer parameters (all optional)
|
|
36
|
+
init_params << "?#{prop}: #{type}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
collections.each do |name_sym, meta|
|
|
40
|
+
elem_type = rbs_elem_type_for(meta)
|
|
41
|
+
lines << " attr_accessor #{name_sym}: ::Array[#{elem_type}]"
|
|
42
|
+
|
|
43
|
+
# Add to initializer parameters (all optional, collections accept arrays or individual items)
|
|
44
|
+
init_params << "?#{name_sym}: ::Array[#{elem_type}]"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Add initializer signature
|
|
48
|
+
lines << ""
|
|
49
|
+
lines << if init_params.any?
|
|
50
|
+
" def initialize: (#{init_params.join(", ")}, **untyped) -> void"
|
|
51
|
+
else
|
|
52
|
+
" def initialize: (**untyped) -> void"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
lines << "end"
|
|
56
|
+
lines.join("\n")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def rbs_type_for(meta)
|
|
62
|
+
if meta[:twin]
|
|
63
|
+
rbs_class_name(meta[:twin])
|
|
64
|
+
elsif meta[:nested_class]
|
|
65
|
+
rbs_class_name(meta[:nested_class])
|
|
66
|
+
else
|
|
67
|
+
dry_type_to_rbs(meta[:type])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def rbs_elem_type_for(meta)
|
|
72
|
+
if meta[:element_twin]
|
|
73
|
+
rbs_class_name(meta[:element_twin])
|
|
74
|
+
else
|
|
75
|
+
"untyped"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def rbs_class_name(klass)
|
|
80
|
+
return "untyped" unless klass&.name
|
|
81
|
+
|
|
82
|
+
"::#{klass.name}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def dry_type_to_rbs(dry_type)
|
|
86
|
+
return "untyped" unless dry_type
|
|
87
|
+
|
|
88
|
+
if dry_type.respond_to?(:primitive) && dry_type.primitive
|
|
89
|
+
prim = dry_type.primitive
|
|
90
|
+
if [TrueClass, FalseClass].include?(prim)
|
|
91
|
+
"bool"
|
|
92
|
+
elsif prim.is_a?(Class) && prim.name
|
|
93
|
+
"::#{prim.name}"
|
|
94
|
+
else
|
|
95
|
+
"untyped"
|
|
96
|
+
end
|
|
97
|
+
else
|
|
98
|
+
s =
|
|
99
|
+
begin
|
|
100
|
+
dry_type.to_s
|
|
101
|
+
rescue StandardError
|
|
102
|
+
""
|
|
103
|
+
end
|
|
104
|
+
i =
|
|
105
|
+
begin
|
|
106
|
+
dry_type.inspect
|
|
107
|
+
rescue StandardError
|
|
108
|
+
""
|
|
109
|
+
end
|
|
110
|
+
blob = [s, i, dry_type.class.name].join(" ")
|
|
111
|
+
return "bool" if blob.include?("Bool")
|
|
112
|
+
return "::Integer" if blob.include?("Integer")
|
|
113
|
+
return "::String" if blob.include?("String")
|
|
114
|
+
return "::Float" if blob.include?("Float")
|
|
115
|
+
|
|
116
|
+
begin
|
|
117
|
+
v1 = dry_type.call(true)
|
|
118
|
+
v2 = dry_type.call(false)
|
|
119
|
+
return "bool" if [true, false].include?(v1) && [true, false].include?(v2)
|
|
120
|
+
rescue StandardError
|
|
121
|
+
# Expected: Type coercion may fail for non-boolean types.
|
|
122
|
+
# Continue to fallback 'untyped'.
|
|
123
|
+
end
|
|
124
|
+
"untyped"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Minitwin
|
|
4
|
+
module ClassMethods
|
|
5
|
+
module TypesHelper
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def dry_type_primitive(type)
|
|
9
|
+
return nil unless type.respond_to?(:primitive)
|
|
10
|
+
|
|
11
|
+
type.primitive
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def type_default_value(type)
|
|
15
|
+
primitive_class = dry_type_primitive(type)
|
|
16
|
+
|
|
17
|
+
# :nocov:
|
|
18
|
+
case primitive_class
|
|
19
|
+
when Integer then 0
|
|
20
|
+
when String then ""
|
|
21
|
+
when TrueClass, FalseClass then false
|
|
22
|
+
else
|
|
23
|
+
# Fallback: inspect type representation for hints
|
|
24
|
+
infer_default_from_type_string(type)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def infer_default_from_type_string(type)
|
|
29
|
+
# Build type description from multiple sources for better inference
|
|
30
|
+
parts = [type.to_s, type.class.name]
|
|
31
|
+
begin
|
|
32
|
+
parts << type.inspect
|
|
33
|
+
rescue StandardError
|
|
34
|
+
# Type.inspect may fail for some custom types
|
|
35
|
+
end
|
|
36
|
+
type_description = parts.compact.join(" ")
|
|
37
|
+
|
|
38
|
+
return 0 if type_description.include?("Integer")
|
|
39
|
+
return "" if type_description.include?("String")
|
|
40
|
+
return false if type_description.include?("Bool")
|
|
41
|
+
|
|
42
|
+
nil
|
|
43
|
+
rescue StandardError
|
|
44
|
+
# Expected: Type introspection may fail for custom or complex types.
|
|
45
|
+
# Return nil as a safe default.
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def coerce_with_type(raw_value, type)
|
|
50
|
+
coerced = attempt_type_coercion(raw_value, type)
|
|
51
|
+
primitive_class = dry_type_primitive(type)
|
|
52
|
+
|
|
53
|
+
# Force string conversion if type is String but coercion didn't produce one
|
|
54
|
+
if raw_value && !coerced.is_a?(String) && primitive_class == String
|
|
55
|
+
coerced = raw_value.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
coerced
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def attempt_type_coercion(raw_value, type)
|
|
62
|
+
return raw_value unless type
|
|
63
|
+
|
|
64
|
+
type.call(raw_value)
|
|
65
|
+
rescue *Minitwin.send(:coercion_error_classes)
|
|
66
|
+
raw_value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "class_methods/dsl"
|
|
4
|
+
require_relative "class_methods/constructors"
|
|
5
|
+
require_relative "class_methods/rbs"
|
|
6
|
+
require_relative "class_methods/caches"
|
|
7
|
+
require_relative "class_methods/types_helper"
|
|
8
|
+
require_relative "class_methods/coercion"
|
|
9
|
+
|
|
10
|
+
class Minitwin
|
|
11
|
+
# Class-level DSL and public constructors split into focused modules.
|
|
12
|
+
module ClassMethods
|
|
13
|
+
include Minitwin::ClassMethods::Dsl
|
|
14
|
+
include Minitwin::ClassMethods::Constructors
|
|
15
|
+
include Minitwin::ClassMethods::Rbs
|
|
16
|
+
include Minitwin::ClassMethods::Caches
|
|
17
|
+
include Minitwin::ClassMethods::TypesHelper
|
|
18
|
+
include Minitwin::ClassMethods::Coercion
|
|
19
|
+
|
|
20
|
+
# Limit DSL surface to class body usage
|
|
21
|
+
private :property, :collection, :nested
|
|
22
|
+
|
|
23
|
+
# Make constructors and RBS API public
|
|
24
|
+
public :from_hash, :from_json, :from_params, :from_object, :from_objects, :from_collection, :to_rbs
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class Minitwin
|
|
5
|
+
# Instance construction and low-level helpers used by the DSL-generated
|
|
6
|
+
# accessors. Filters unknown keys on initialize and seeds nested block
|
|
7
|
+
# properties so that validations on nested twins can run.
|
|
8
|
+
module Initialization
|
|
9
|
+
# Cache constant references for JIT optimization
|
|
10
|
+
ALIASES_VAR = Minitwin::DYNAMIC_ALIASES_VAR
|
|
11
|
+
ALIASES_REV_VAR = Minitwin::DYNAMIC_ALIASES_REV_VAR
|
|
12
|
+
|
|
13
|
+
# Forbidden method names that should never be aliased for security reasons
|
|
14
|
+
FORBIDDEN_ALIAS_NAMES = %i[
|
|
15
|
+
eval instance_eval class_eval module_eval
|
|
16
|
+
send __send__ public_send
|
|
17
|
+
method_missing respond_to_missing?
|
|
18
|
+
define_method remove_method undef_method
|
|
19
|
+
instance_variable_get instance_variable_set
|
|
20
|
+
instance_variables instance_variable_defined?
|
|
21
|
+
const_get const_set
|
|
22
|
+
class_variable_get class_variable_set
|
|
23
|
+
binding tap then yield_self to_proc
|
|
24
|
+
freeze __id__ object_id
|
|
25
|
+
== equal? eql? hash <=>
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
#: (**untyped) -> instance
|
|
29
|
+
def initialize(**args)
|
|
30
|
+
allowed_keys = self.class.send(:allowed_attribute_keys)
|
|
31
|
+
|
|
32
|
+
args.select! { |arg, _| allowed_keys.include?(arg.to_sym) }
|
|
33
|
+
|
|
34
|
+
getter_defaults = {}
|
|
35
|
+
self.class.block_properties.each do |method|
|
|
36
|
+
getter_defaults[method] = {} if allowed_keys.include?(method)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attrs = getter_defaults.merge(args)
|
|
40
|
+
|
|
41
|
+
# Skip per-setter alias recomputation during bulk init; recompute once after
|
|
42
|
+
@__skip_alias_recompute__ = true
|
|
43
|
+
if Minitwin.send(:active_model_initialized?, self.class)
|
|
44
|
+
super(attrs)
|
|
45
|
+
else
|
|
46
|
+
# :nocov: (exercised only without ActiveModel; covered by subprocess test)
|
|
47
|
+
attrs.each { |k, v| assign_attribute(method: k, value: v) }
|
|
48
|
+
# :nocov:
|
|
49
|
+
end
|
|
50
|
+
@__skip_alias_recompute__ = false
|
|
51
|
+
|
|
52
|
+
__recompute_dynamic_aliases__ if self.class.dynamic_aliases?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def assign_attribute(method:, value:)
|
|
58
|
+
if respond_to?("#{method}=")
|
|
59
|
+
send("#{method}=", value)
|
|
60
|
+
else
|
|
61
|
+
instance_variable_set("@#{method}", value)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def attribute_methods
|
|
66
|
+
self.class.send(:allowed_attribute_keys_array)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def define_instance_variable(name:, value:)
|
|
70
|
+
instance_variable_set(Minitwin::Utils.ivar_name(name), value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Define or update per-instance alias methods for properties/collections
|
|
74
|
+
# where `as:` was provided as a Proc. The Proc is executed in the context
|
|
75
|
+
# of the instance to compute the alias name.
|
|
76
|
+
def __recompute_dynamic_aliases__
|
|
77
|
+
instance_variable_set(ALIASES_VAR, {}) unless instance_variable_defined?(ALIASES_VAR)
|
|
78
|
+
instance_variable_set(ALIASES_REV_VAR, {}) unless instance_variable_defined?(ALIASES_REV_VAR)
|
|
79
|
+
|
|
80
|
+
# Handle scalar properties and collections
|
|
81
|
+
__recompute_aliases_for_collection__(:properties)
|
|
82
|
+
__recompute_aliases_for_collection__(:collections)
|
|
83
|
+
|
|
84
|
+
# Handle nested dynamic aliases (registered by DSL#nested)
|
|
85
|
+
__recompute_nested_aliases__
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def __recompute_aliases_for_collection__(collection_method)
|
|
89
|
+
return unless self.class.respond_to?(collection_method)
|
|
90
|
+
|
|
91
|
+
self.class.public_send(collection_method).each do |key, meta|
|
|
92
|
+
as_meta = meta[:as]
|
|
93
|
+
next unless as_meta.is_a?(Proc)
|
|
94
|
+
|
|
95
|
+
alias_name = __compute_alias_name__(as_meta)
|
|
96
|
+
next if alias_name.nil?
|
|
97
|
+
|
|
98
|
+
__apply_dynamic_alias__(key, alias_name)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def __recompute_nested_aliases__
|
|
103
|
+
return unless self.class.respond_to?(:dynamic_nested_aliases)
|
|
104
|
+
|
|
105
|
+
self.class.dynamic_nested_aliases.each do |entry|
|
|
106
|
+
as_meta = entry[:as]
|
|
107
|
+
target = entry[:target]
|
|
108
|
+
|
|
109
|
+
if as_meta.is_a?(Proc)
|
|
110
|
+
alias_name = __compute_nested_alias_name__(entry)
|
|
111
|
+
next if alias_name.nil?
|
|
112
|
+
|
|
113
|
+
__apply_dynamic_alias__(target, alias_name)
|
|
114
|
+
else
|
|
115
|
+
# Static alias recorded by nested to support protected inner readers
|
|
116
|
+
__apply_dynamic_alias__(target, as_meta)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def __compute_alias_name__(as_proc)
|
|
122
|
+
instance_exec(&as_proc)
|
|
123
|
+
rescue StandardError
|
|
124
|
+
# Expected: Dynamic alias proc may fail or return invalid names.
|
|
125
|
+
# Return nil to skip this alias definition.
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def __compute_nested_alias_name__(entry)
|
|
130
|
+
obj = public_send(entry[:group])
|
|
131
|
+
obj = Minitwin::Utils.traverse_path(obj, entry[:path][0..-2])
|
|
132
|
+
obj.instance_exec(&entry[:as])
|
|
133
|
+
rescue StandardError
|
|
134
|
+
# Expected: Nested path traversal or dynamic alias proc may fail.
|
|
135
|
+
# Return nil to skip this alias definition.
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def __apply_dynamic_alias__(target_method, alias_name)
|
|
140
|
+
unless alias_name.is_a?(String) || alias_name.is_a?(Symbol)
|
|
141
|
+
raise ArgumentError, "Invalid alias name #{alias_name.inspect}: must be a String or Symbol"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
alias_key = alias_name.to_sym
|
|
145
|
+
aliases = instance_variable_get(ALIASES_VAR)
|
|
146
|
+
aliases_rev = instance_variable_get(ALIASES_REV_VAR)
|
|
147
|
+
|
|
148
|
+
# Security check: prevent aliasing to forbidden method names
|
|
149
|
+
if FORBIDDEN_ALIAS_NAMES.include?(alias_key)
|
|
150
|
+
raise ArgumentError, "Cannot define dynamic alias '#{alias_key}': forbidden method name for security reasons"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
prev = aliases[target_method]
|
|
154
|
+
if prev && prev != alias_key
|
|
155
|
+
begin
|
|
156
|
+
singleton_class.send(:remove_method, prev)
|
|
157
|
+
rescue NameError
|
|
158
|
+
# Expected: method may not exist if previously failed to define
|
|
159
|
+
end
|
|
160
|
+
aliases_rev.delete(prev)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Collision checks: alias already used by another target or an existing method
|
|
164
|
+
if aliases_rev.key?(alias_key) && aliases_rev[alias_key] != target_method
|
|
165
|
+
raise ArgumentError, "Dynamic alias '#{alias_key}' already defined for '#{aliases_rev[alias_key]}'"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
if respond_to?(alias_key, true) && aliases_rev[alias_key] != target_method
|
|
169
|
+
raise ArgumentError, "Cannot define dynamic alias '#{alias_key}': method already exists"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Define forwarding method on the singleton class
|
|
173
|
+
singleton_class.send(:define_method, alias_key) do
|
|
174
|
+
send(target_method)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
aliases[target_method] = alias_key
|
|
178
|
+
aliases_rev[alias_key] = target_method
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Public: expose current dynamic aliases as a Hash of alias_name => target_method
|
|
182
|
+
def dynamic_aliases
|
|
183
|
+
return {} unless instance_variable_defined?(ALIASES_REV_VAR) && instance_variable_get(ALIASES_REV_VAR)
|
|
184
|
+
|
|
185
|
+
instance_variable_get(ALIASES_REV_VAR).dup
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class Minitwin
|
|
5
|
+
# Serialization to Hash/JSON and ActiveModel validation aggregation.
|
|
6
|
+
# Converts nested twins recursively and preserves array items (dropping only
|
|
7
|
+
# nil). When ActiveModel validations are available, nested errors are
|
|
8
|
+
# surfaced on the parent using dot/bracket notation.
|
|
9
|
+
module Serialization
|
|
10
|
+
# Cache constant references for JIT optimization
|
|
11
|
+
ALIASES_VAR = Minitwin::DYNAMIC_ALIASES_VAR
|
|
12
|
+
NESTED_PREFIX = Minitwin::NESTED_READER_PREFIX
|
|
13
|
+
|
|
14
|
+
#: (render_nil: bool) -> Hash[Symbol, untyped]
|
|
15
|
+
def to_hash(render_nil: false)
|
|
16
|
+
hash = Minitwin.hash_klass.new
|
|
17
|
+
|
|
18
|
+
methods_to_serialize = self.class.send(:serializable_getters)
|
|
19
|
+
|
|
20
|
+
methods_to_serialize.each do |method|
|
|
21
|
+
value = send(method)
|
|
22
|
+
next if value.nil? && !render_nil
|
|
23
|
+
|
|
24
|
+
hash[method] = transform_value_for_serialization(value)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Include dynamic alias keys (defined per instance via `as: -> { ... }`).
|
|
28
|
+
if instance_variable_defined?(ALIASES_VAR)
|
|
29
|
+
aliases = instance_variable_get(ALIASES_VAR)
|
|
30
|
+
if aliases && !aliases.empty?
|
|
31
|
+
aliases.each do |target_method, alias_method|
|
|
32
|
+
# Skip nested proxy aliases at the top level; nested groups
|
|
33
|
+
# serialize under their container key only.
|
|
34
|
+
next if target_method.is_a?(Symbol) && target_method.to_s.start_with?(NESTED_PREFIX)
|
|
35
|
+
|
|
36
|
+
# Read the value from the original target method to avoid issues if
|
|
37
|
+
# the alias method is overridden. Apply the same transformation rules
|
|
38
|
+
# as above for nested twins and arrays.
|
|
39
|
+
value = send(target_method)
|
|
40
|
+
next if value.nil? && !render_nil
|
|
41
|
+
|
|
42
|
+
hash[alias_method] = transform_value_for_serialization(value)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
hash
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
alias to_h to_hash
|
|
51
|
+
|
|
52
|
+
#: (**untyped) -> String
|
|
53
|
+
def to_json(**)
|
|
54
|
+
to_hash(**).to_json
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
#: () -> Hash[Symbol, untyped]
|
|
58
|
+
def attributes
|
|
59
|
+
# Use setter-based attribute names and read via `send` to allow
|
|
60
|
+
# accessing protected original readers when aliases (`as:`) are used.
|
|
61
|
+
attribute_methods.each_with_object({}) do |m, h|
|
|
62
|
+
h[m] = send(m) if respond_to?(m, true)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
#: () -> bool
|
|
67
|
+
def valid?
|
|
68
|
+
# If ActiveModel validations are available and included, run them and
|
|
69
|
+
# aggregate nested errors. Otherwise, consider the twin valid.
|
|
70
|
+
if defined?(ActiveModel::Validations) && self.class.ancestors.include?(ActiveModel::Validations)
|
|
71
|
+
super
|
|
72
|
+
|
|
73
|
+
self.class.block_properties.each do |property|
|
|
74
|
+
child = send(property)
|
|
75
|
+
next unless child.respond_to?(:valid?)
|
|
76
|
+
|
|
77
|
+
child.valid?
|
|
78
|
+
child.errors.each do |attribute|
|
|
79
|
+
errors.add("#{property}.#{attribute.attribute}", attribute.message)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
self.class.collection_properties.each do |property|
|
|
84
|
+
send(property).each_with_index do |value, index|
|
|
85
|
+
next unless value.respond_to?(:valid?)
|
|
86
|
+
|
|
87
|
+
value.valid?
|
|
88
|
+
value.errors.each do |attribute|
|
|
89
|
+
errors.add("#{property}[#{index}].#{attribute.attribute}", attribute.message)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
errors.empty?
|
|
95
|
+
else
|
|
96
|
+
# When ActiveModel is not available, consider the twin valid by default.
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: () -> String
|
|
102
|
+
def inspect
|
|
103
|
+
attrs = to_hash.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
|
|
104
|
+
"#<#{self.class.name} #{attrs}>"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Internal helper for PrettyPrint. Do not call this on your own.
|
|
108
|
+
#: (PP) -> void
|
|
109
|
+
def pretty_print(pretty_printer)
|
|
110
|
+
pretty_printer.object_group(self) do
|
|
111
|
+
pretty_printer.breakable
|
|
112
|
+
pretty_printer.seplist(
|
|
113
|
+
ordered_attributes_for_pp,
|
|
114
|
+
-> {
|
|
115
|
+
pretty_printer.text(",")
|
|
116
|
+
pretty_printer.breakable
|
|
117
|
+
}
|
|
118
|
+
) do |(name, value)|
|
|
119
|
+
pretty_printer.group do
|
|
120
|
+
pretty_printer.text name.to_s
|
|
121
|
+
pretty_printer.text ": "
|
|
122
|
+
pretty_printer.pp value
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def transform_value_for_serialization(value)
|
|
131
|
+
case value
|
|
132
|
+
when Minitwin
|
|
133
|
+
value.to_hash
|
|
134
|
+
when Array
|
|
135
|
+
value.filter_map { |item| item.respond_to?(:to_hash) ? item.to_hash : item }
|
|
136
|
+
else
|
|
137
|
+
value
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def ordered_attributes_for_pp
|
|
142
|
+
methods = ordered_methods_for_pp
|
|
143
|
+
attrs = methods.map { |m| [display_name_for(m), send(m)] }
|
|
144
|
+
attrs + dynamic_aliases_for_pp
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def ordered_methods_for_pp
|
|
148
|
+
ordered = self.class.send(:property_order)
|
|
149
|
+
all_methods = self.class.send(:serializable_getters)
|
|
150
|
+
ordered.select { |m| all_methods.include?(m) } + (all_methods - ordered)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def display_name_for(method)
|
|
154
|
+
meta = self.class.properties[method] || self.class.collections[method]
|
|
155
|
+
meta && meta[:as] && !meta[:as].is_a?(Proc) ? meta[:as] : method
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def dynamic_aliases_for_pp
|
|
159
|
+
return [] unless instance_variable_defined?(ALIASES_VAR)
|
|
160
|
+
|
|
161
|
+
aliases = instance_variable_get(ALIASES_VAR)
|
|
162
|
+
return [] unless aliases && !aliases.empty?
|
|
163
|
+
|
|
164
|
+
aliases.filter_map do |target_method, alias_method|
|
|
165
|
+
next if target_method.is_a?(Symbol) && target_method.to_s.start_with?(NESTED_PREFIX)
|
|
166
|
+
|
|
167
|
+
[alias_method, send(target_method)]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
end
|
|
172
|
+
end
|