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,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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Minitwin
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load File.join(__dir__, "..", "tasks", "minitwin.rake")
7
+ end
8
+ end
9
+ 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