u-attributes 3.0.2 → 3.1.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.
@@ -3,12 +3,14 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rake", "~> 13.0"
6
- gem "u-case", "~> 4.5", ">= 4.5.1"
6
+ gem "u-case", "~> 5.7"
7
7
 
8
8
  group :test do
9
+ gem "logger"
10
+ gem "stringio"
9
11
  gem "minitest", "~> 6.0"
10
- gem "simplecov", "~> 0.22.0", require: false
11
12
  gem "ostruct", "~> 0.6.3"
13
+ gem "simplecov", "~> 0.22.0", require: false
12
14
  gem "activemodel", "~> 8.1.0"
13
15
  end
14
16
 
@@ -3,12 +3,14 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rake", "~> 13.0"
6
- gem "u-case", "~> 4.5", ">= 4.5.1"
6
+ gem "u-case", "~> 5.7"
7
7
 
8
8
  group :test do
9
+ gem "logger"
10
+ gem "stringio"
9
11
  gem "minitest", "~> 6.0"
10
- gem "simplecov", "~> 0.22.0", require: false
11
12
  gem "ostruct", "~> 0.6.3"
13
+ gem "simplecov", "~> 0.22.0", require: false
12
14
  gem "activemodel", branch: "main", git: "https://github.com/rails/rails"
13
15
  end
14
16
 
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Micro
4
+ module Attributes
5
+ # Composition behavior baked into every `Micro::Attributes` includer.
6
+ #
7
+ # - `Coercion` (prepended): hashes assigned to an attribute whose
8
+ # `accept:` is another `Micro::Attributes` class are auto-built
9
+ # into instances of that class. Errors on the constructed child
10
+ # bubble up as `'is invalid'` markers in the parent's
11
+ # `attributes_errors` (when `Accept` is active).
12
+ # - `Instance#__validate_nested_entities__` (included): walks nested
13
+ # `Micro::Attributes` children and surfaces their invalidity into
14
+ # ActiveModel `errors`. Auto-registered by
15
+ # `Features::ActiveModelValidations.included`.
16
+ module Composition
17
+ module Coercion
18
+ private
19
+
20
+ def __micro_attributes_hash_constructible?(klass)
21
+ arity = klass.instance_method(:initialize).arity
22
+ arity == 1 || arity == -1 || arity == -2
23
+ end
24
+
25
+ def ___attribute_assign(key, init_hash, attribute_data)
26
+ accept = attribute_data[1]
27
+
28
+ # Only coerce when the target class has a constructor that
29
+ # accepts exactly one positional arg (or one + variadic). The
30
+ # arity check accepts:
31
+ # - `1` — `def initialize(arg)` (Features::Initialize, custom)
32
+ # - `-1`, `-2` — `def initialize(*a)` / `def initialize(a, *b)`
33
+ # And rejects:
34
+ # - `0` — `Object#initialize`, would crash on hash arg
35
+ # - `>= 2` — `def initialize(a, b)` etc., would crash on hash arg
36
+ if accept[0] == :accept &&
37
+ (klass = accept[1]).is_a?(::Class) &&
38
+ klass.include?(::Micro::Attributes) &&
39
+ __micro_attributes_hash_constructible?(klass)
40
+ value = init_hash[key]
41
+
42
+ if value.is_a?(::Hash)
43
+ init_hash = init_hash.dup
44
+ begin
45
+ init_hash[key] = klass.new(value)
46
+ rescue ::ArgumentError
47
+ # Child construction blew up — typically a missing required
48
+ # keyword on the nested class, or a strict-mode rejection.
49
+ # Leave the raw hash in place so Accept's KindOf check
50
+ # ("expected to be a kind of <Klass>") rejects it into
51
+ # `attributes_errors` instead of escaping as an exception.
52
+ # This preserves the controlled `Failure(:invalid_attributes)`
53
+ # envelope for u-case use cases that hold `accept:` nested
54
+ # entities.
55
+ init_hash[key] = value
56
+ end
57
+ end
58
+ end
59
+
60
+ super(key, init_hash, attribute_data)
61
+
62
+ # Bubble a marker for nested-entity errors — but only for PUBLIC
63
+ # attributes. Mirror Accept's visibility gate so private/protected
64
+ # attribute names don't leak through `attributes_errors`.
65
+ child = instance_variable_get("@#{key}")
66
+ if child.is_a?(::Object) &&
67
+ child.class.include?(::Micro::Attributes) &&
68
+ child.respond_to?(:attributes_errors?) && child.attributes_errors? &&
69
+ @__attributes_errors && !@__attributes_errors.key?(key) &&
70
+ attribute_data[3] == :public
71
+ @__attributes_errors[key] = 'is invalid'
72
+ end
73
+ end
74
+ end
75
+
76
+ module Instance
77
+ def __validate_nested_entities__
78
+ return unless respond_to?(:errors)
79
+
80
+ # Iterate only PUBLIC attributes so private/protected nested
81
+ # entity names never leak through ActiveModel `errors` /
82
+ # `full_messages`. Mirrors the bubble's visibility gate above.
83
+ self.class.attributes_by_visibility[:public].each do |attr_name|
84
+ child = instance_variable_get("@#{attr_name}")
85
+
86
+ next unless child.is_a?(::Object) && child.class.include?(::Micro::Attributes)
87
+
88
+ child_invalid =
89
+ if child.respond_to?(:valid?)
90
+ # If the child already has errors, treat it as invalid
91
+ # without re-running `valid?` — AM's `valid?` calls
92
+ # `errors.clear` first, which would wipe any errors the
93
+ # caller (or another validator pass) had added to a
94
+ # shared child instance.
95
+ child.errors.any? || !child.valid?
96
+ elsif child.respond_to?(:attributes_errors?)
97
+ child.attributes_errors?
98
+ else
99
+ false
100
+ end
101
+
102
+ errors.add(attr_name.to_sym, 'is invalid') if child_invalid
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -35,7 +35,7 @@ module Micro::Attributes
35
35
 
36
36
  KeepProc = -> validation_data { validation_data[0] == :accept && validation_data[1] == Proc }
37
37
 
38
- def __attribute_assign(key, init_hash, attribute_data)
38
+ def ___attribute_assign(key, init_hash, attribute_data)
39
39
  validation_data = attribute_data[1]
40
40
 
41
41
  value_to_assign = FetchValueToAssign.(init_hash, init_hash[key], attribute_data, KeepProc.(validation_data))
@@ -66,6 +66,14 @@ module Micro::Attributes
66
66
  when base <= Features::Accept then base.send(:include, WithAccept)
67
67
  else base.send(:include, Standard)
68
68
  end
69
+
70
+ # When AM is mixed into any `Micro::Attributes` includer, auto-register
71
+ # a validator that recurses through nested-attribute children so
72
+ # `valid?` reflects deep descendant invalidity. The method is
73
+ # provided by `Composition::Instance`.
74
+ if base.include?(::Micro::Attributes)
75
+ base.validate(:__validate_nested_entities__)
76
+ end
69
77
  rescue LoadError
70
78
  end
71
79
  end
@@ -106,13 +106,80 @@ module Micro
106
106
  ].join
107
107
 
108
108
  def self.fetch_keys(args)
109
- keys = Array(args).dup.map { |name| fetch_key(name) }
109
+ keys = Array(args).flat_map { |name| split_strict_hash(name) }.map { |name| fetch_key(name) }
110
110
 
111
111
  raise ArgumentError, INVALID_NAME if keys.empty? || !(keys - KEYS).empty?
112
112
 
113
113
  yield(keys)
114
114
  end
115
115
 
116
+ # Normalize a Hash arg passed to `Micro::Attributes.with(...)` into
117
+ # the per-entry form that `fetch_key` consumes.
118
+ #
119
+ # Handles BOTH:
120
+ #
121
+ # - The legacy single-key strict form (`{initialize: :strict}`,
122
+ # `{accept: :strict}`) — preserved as-is, returned in a single-key
123
+ # hash so the existing `fetch_key` branch matches it.
124
+ # - The new self-documenting hash API:
125
+ #
126
+ # Micro::Attributes.with(
127
+ # initialize: true | :strict,
128
+ # accept: true | :strict,
129
+ # diff: true,
130
+ # keys_as: :symbol | :string | :indifferent,
131
+ # active_model: :validations
132
+ # )
133
+ #
134
+ # Each entry expands into the corresponding legacy feature symbol
135
+ # (or single-key strict hash). `false`/`nil`/the no-op variants
136
+ # for `keys_as` (`:string`/`:indifferent`) expand to nothing, so
137
+ # "omit a key = feature off" reads naturally.
138
+ def self.split_strict_hash(arg)
139
+ return [arg] unless arg.is_a?(Hash)
140
+
141
+ arg.flat_map { |key, value| expand_hash_entry(key, value) }
142
+ end
143
+
144
+ def self.expand_hash_entry(key, value)
145
+ case key
146
+ when :initialize
147
+ case value
148
+ when true then [:initialize]
149
+ when :strict then [{ initialize: :strict }]
150
+ when false, nil then []
151
+ else [{ key => value }]
152
+ end
153
+ when :accept
154
+ case value
155
+ when true then [:accept]
156
+ when :strict then [{ accept: :strict }]
157
+ when false, nil then []
158
+ else [{ key => value }]
159
+ end
160
+ when :diff
161
+ case value
162
+ when true then [:diff]
163
+ when false, nil then []
164
+ else [{ key => value }]
165
+ end
166
+ when :keys_as
167
+ case value
168
+ when :symbol then [:keys_as_symbol]
169
+ when :string, :indifferent, nil, false then []
170
+ else [{ key => value }]
171
+ end
172
+ when :active_model
173
+ case value
174
+ when :validations then [:activemodel_validations]
175
+ when nil, false then []
176
+ else [{ key => value }]
177
+ end
178
+ else
179
+ [{ key => value }]
180
+ end
181
+ end
182
+
116
183
  def self.remove_base_if_has_strict(keys)
117
184
  keys.delete_if { |key| key == INIT } if keys.include?(INIT_STRICT)
118
185
  keys.delete_if { |key| key == ACCEPT } if keys.include?(ACCEPT_STRICT)
@@ -108,9 +108,47 @@ module Micro
108
108
  protected(name) if Options.protected?(visibility_index)
109
109
  end
110
110
 
111
+ # Re-apply visibility for an already-defined attribute. Used by
112
+ # `attribute!` so a subclass can promote a private/protected
113
+ # parent attribute back to public (or change visibility in either
114
+ # direction). Without this, `__attributes_data__` would say one
115
+ # thing while the actual reader's Ruby visibility said another.
116
+ #
117
+ # Note — `attribute!` is authoritative here: if the user defined a
118
+ # custom reader method (`def name; ...; end`) between the parent's
119
+ # `attribute` and the child's `attribute!`, this call will adjust
120
+ # that custom method's Ruby visibility too. That matches the
121
+ # documented "redefine these attributes" contract of `attribute!`.
122
+ def __attribute_reapply_visibility(name, visibility_index)
123
+ [Options::PUBLIC, Options::PRIVATE, Options::PROTECTED].each do |idx|
124
+ __attributes_groups[idx].delete(name)
125
+ end
126
+ __attributes_groups[visibility_index] << name
127
+
128
+ if Options.private?(visibility_index)
129
+ private(name)
130
+ elsif Options.protected?(visibility_index)
131
+ protected(name)
132
+ else
133
+ public(name)
134
+ end
135
+ end
136
+
137
+ # Sync the required set with the new options. Name kept for backwards
138
+ # compat with downstream gems (u-case v4) that may introspect it —
139
+ # the method is now add-or-remove rather than add-only so that
140
+ # `attribute!` on a child can relax a parent's required attribute
141
+ # by giving it a default (or vice-versa).
142
+ #
143
+ # When a `default:` is present, the attribute is NEVER required —
144
+ # `default:` always wins. `attribute :foo, default: X, required: true`
145
+ # treats `required: true` as a docs hint; the default supplies a value
146
+ # when none is passed, matching the 3.0.x behavior.
111
147
  def __attributes_required_add(name, opt, hasnt_default)
112
- if opt[:required] || (attributes_are_all_required? && hasnt_default)
148
+ if hasnt_default && (opt[:required] || attributes_are_all_required?)
113
149
  __attributes_required__.add(name)
150
+ else
151
+ __attributes_required__.delete(name)
114
152
  end
115
153
 
116
154
  nil
@@ -119,10 +157,10 @@ module Micro
119
157
  def __attributes_data_to_assign(name, opt, visibility_index)
120
158
  hasnt_default = !opt.key?(:default)
121
159
 
122
- default = hasnt_default ? __attributes_required_add(name, opt, hasnt_default) : opt[:default]
160
+ __attributes_required_add(name, opt, hasnt_default)
123
161
 
124
162
  [
125
- default,
163
+ hasnt_default ? nil : opt[:default],
126
164
  Options.for_accept(opt),
127
165
  opt[:freeze],
128
166
  Options.visibility_name_from_index(visibility_index)
@@ -144,6 +182,7 @@ module Micro
144
182
 
145
183
  if can_overwrite || !has_attribute
146
184
  __attributes_data__[name] = __attributes_data_to_assign(name, opt, visibility_index)
185
+ __attribute_reapply_visibility(name, visibility_index) if has_attribute
147
186
  end
148
187
 
149
188
  __call_after_attribute_assign__(name, opt)
@@ -175,10 +214,152 @@ module Micro
175
214
  __attributes_public.member?(key)
176
215
  end
177
216
 
178
- def attribute(name, options = Kind::Empty::HASH)
217
+ def attribute(name, options = Kind::Empty::HASH, &block)
218
+ options = __micro_attributes_block_options__(name, options, block) if block
179
219
  __attribute_assign(name, false, options)
180
220
  end
181
221
 
222
+ # Mix more `Micro::Attributes` features into this class. Sugar for
223
+ # `include Micro::Attributes.with(*names)` — accepts the same forms,
224
+ # including the hash-style API (e.g. `with initialize: :strict`).
225
+ #
226
+ # with :keys_as_symbol
227
+ # with :keys_as_symbol, :activemodel_validations
228
+ # with initialize: :strict, accept: :strict
229
+ def with(*names)
230
+ send(:include, ::Micro::Attributes.with(*names))
231
+ end
232
+
233
+ private
234
+
235
+ def __micro_attributes_block_options__(name, options, block)
236
+ if options.key?(:accept)
237
+ raise ArgumentError,
238
+ "attribute #{name.inspect}: cannot pass both `accept:` and a block — " \
239
+ "the block already builds an inline class for `accept:`. " \
240
+ "Use one or the other."
241
+ end
242
+
243
+ options = options.dup
244
+ options[:accept] = __micro_attributes_build_inline_class__(name, block)
245
+ options
246
+ end
247
+
248
+ # Build an anonymous nested class for `attribute :foo do ... end`.
249
+ #
250
+ # The inline class is wired with `Micro::Attributes.with(...)` so it
251
+ # always has a hash-keyword constructor and accept-validation
252
+ # machinery (the bare minimum for block-form to be useful). On top
253
+ # of that default, every `Features::*` module already in the host's
254
+ # ancestors is replayed — so a `KeysAsSymbol` / `ActiveModelValidations`
255
+ # / `Strict` host yields an inline child with the same mix.
256
+ #
257
+ # This handles three host patterns:
258
+ # - `include Micro::Attributes.with(...)` — features come from With::*
259
+ # in ancestors, replayed below.
260
+ # - bare `include Micro::Attributes` — defaults to init+accept.
261
+ # - direct `include Micro::Attributes::Features::*` (the `u-case`
262
+ # pattern) — same detection path picks them up.
263
+ def __micro_attributes_build_inline_class__(name, block)
264
+ klass = Class.new
265
+ klass.send(:include, ::Micro::Attributes.with(*__micro_attributes_inline_features__))
266
+
267
+ klass.class_eval(&block)
268
+
269
+ # Lazy outer label — capture the host class OBJECT (not its
270
+ # `.name` string) so naming resolves AFTER any later constant
271
+ # assignment (matters for `Micro::Attributes.new { ... }`,
272
+ # where the constant is assigned only after the factory returns).
273
+ outer = self
274
+ label_proc = -> { "#{outer.name || outer.inspect}(#{name})" }
275
+
276
+ klass.define_singleton_method(:to_s, &label_proc)
277
+ klass.define_singleton_method(:inspect, &label_proc)
278
+
279
+ # Stop instances from leaking the anonymous class's heap address
280
+ # via the default `Object#inspect`. The new inspect:
281
+ # - uses `self.class.to_s` (stable via the singleton above)
282
+ # - surfaces ONLY public attribute values — consults
283
+ # `attributes_by_visibility[:public]` so private/protected
284
+ # values aren't leaked
285
+ # - which also hides framework ivars like ActiveModel's
286
+ # `@errors`, `@validation_context`, etc., since they aren't
287
+ # declared attributes
288
+ #
289
+ # Skip if the block already defined `inspect` directly on the
290
+ # inline class — user-defined `def inspect` inside the block
291
+ # wins over the macro's default.
292
+ unless klass.instance_methods(false).include?(:inspect)
293
+ klass.send(:define_method, :inspect) do
294
+ public_attrs = self.class.attributes_by_visibility[:public]
295
+ present = public_attrs.select { |n| instance_variable_defined?("@#{n}") }
296
+
297
+ if present.empty?
298
+ "#<#{self.class}>"
299
+ else
300
+ body = present.map { |n| "@#{n}=#{instance_variable_get("@#{n}").inspect}" }.join(', ')
301
+ "#<#{self.class} #{body}>"
302
+ end
303
+ end
304
+ end
305
+
306
+ # ActiveModel-aware naming — but ONLY when the inline class
307
+ # actually has AM mixed in. AM's error renderer reaches for
308
+ # `klass.model_name`, which on an anonymous class defaults to
309
+ # `ActiveModel::Name.new(klass)` and raises "Class name cannot
310
+ # be blank". The singleton override provides the explicit name.
311
+ #
312
+ # We DO NOT define the override on non-AM inline classes,
313
+ # because that would flip `respond_to?(:model_name)` from
314
+ # false → true on AM-less hosts and break any duck-typing
315
+ # feature-detection (`if klass.respond_to?(:model_name); ...`).
316
+ if defined?(::ActiveModel::Validations) && klass.include?(::ActiveModel::Validations)
317
+ klass.define_singleton_method(:model_name) do
318
+ ::ActiveModel::Name.new(self, nil, label_proc.call)
319
+ end
320
+ end
321
+
322
+ klass
323
+ end
324
+
325
+ # Detect every Micro::Attributes feature module already in the host's
326
+ # ancestors and map it back to the args `Micro::Attributes.with` accepts.
327
+ # Always includes `:initialize` and `:accept` defaults so block-form
328
+ # attributes can be hash-constructed and type-checked.
329
+ FEATURE_NAME_TO_ARG = {
330
+ 'Micro::Attributes::Features::Initialize' => :initialize,
331
+ 'Micro::Attributes::Features::Accept' => :accept,
332
+ 'Micro::Attributes::Features::Diff' => :diff,
333
+ 'Micro::Attributes::Features::KeysAsSymbol' => :keys_as_symbol,
334
+ 'Micro::Attributes::Features::ActiveModelValidations' => :activemodel_validations
335
+ }.freeze
336
+
337
+ STRICT_NAME_TO_VARIANT = {
338
+ 'Micro::Attributes::Features::Initialize::Strict' => :initialize,
339
+ 'Micro::Attributes::Features::Accept::Strict' => :accept
340
+ }.freeze
341
+
342
+ def __micro_attributes_inline_features__
343
+ features = [:initialize, :accept]
344
+ strict = {}
345
+
346
+ ancestors.each do |mod|
347
+ next unless mod.is_a?(::Module) && mod.name
348
+
349
+ if (arg = FEATURE_NAME_TO_ARG[mod.name])
350
+ features << arg
351
+ elsif (key = STRICT_NAME_TO_VARIANT[mod.name])
352
+ strict[key] = :strict
353
+ end
354
+ end
355
+
356
+ features.uniq!
357
+ features << strict unless strict.empty?
358
+ features
359
+ end
360
+
361
+ public
362
+
182
363
  RaiseKindError = ->(expected, given) do
183
364
  if (util = Kind.const_get(:KIND, false)) && util.respond_to?(:error!)
184
365
  util.error!(expected, given)
@@ -218,7 +399,8 @@ module Micro
218
399
  module ForSubclasses
219
400
  WRONG_NUMBER_OF_ARGS = 'wrong number of arguments (given 0, expected 1 or more)'.freeze
220
401
 
221
- def attribute!(name, options = Kind::Empty::HASH)
402
+ def attribute!(name, options = Kind::Empty::HASH, &block)
403
+ options = __micro_attributes_block_options__(name, options, block) if block
222
404
  __attribute_assign(name, true, options)
223
405
  end
224
406
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Micro
4
4
  module Attributes
5
- VERSION = '3.0.2'.freeze
5
+ VERSION = '3.1.0'.freeze
6
6
  end
7
7
  end
@@ -8,15 +8,19 @@ module Micro
8
8
  require 'micro/attributes/utils'
9
9
  require 'micro/attributes/diff'
10
10
  require 'micro/attributes/macros'
11
+ require 'micro/attributes/composition'
11
12
  require 'micro/attributes/features'
12
13
 
13
14
  def self.included(base)
14
15
  base.extend(::Micro::Attributes.const_get(:Macros))
16
+ base.send(:prepend, ::Micro::Attributes::Composition::Coercion)
17
+ base.send(:include, ::Micro::Attributes::Composition::Instance)
15
18
 
16
19
  base.class_eval do
17
20
  private_class_method :__attributes, :__attribute_reader
18
21
  private_class_method :__attribute_assign, :__attributes_groups
19
22
  private_class_method :__attributes_required_add, :__attributes_data_to_assign
23
+ private_class_method :__attribute_reapply_visibility
20
24
  end
21
25
 
22
26
  def base.inherited(subclass)
@@ -38,6 +42,64 @@ module Micro
38
42
  Features.all
39
43
  end
40
44
 
45
+ # `Struct.new`-style class factory. Returns a fresh class that includes
46
+ # `Micro::Attributes.with(...)` with the requested features (merged on
47
+ # top of the default preset `{ initialize: true, accept: true }`).
48
+ # The block (if given) is `class_eval`d on the new class so attributes
49
+ # can be declared inline.
50
+ #
51
+ # User = Micro::Attributes.new do
52
+ # attribute :name, accept: String
53
+ # end
54
+ #
55
+ # StrictUser = Micro::Attributes.new(initialize: :strict, accept: :strict) do
56
+ # attribute :name, accept: String
57
+ # attribute :age, accept: Numeric
58
+ # end
59
+ #
60
+ # The preset is overridable per-key — pass `initialize: false` to opt
61
+ # out, or `initialize: :strict` to upgrade. Use the hash API only
62
+ # (positional symbols are intentionally not accepted here so the
63
+ # "preset + override" semantics stay unambiguous).
64
+ NEW_DEFAULTS = { initialize: true, accept: true }.freeze
65
+ private_constant :NEW_DEFAULTS
66
+
67
+ def self.new(options = {}, &block)
68
+ raise ArgumentError, 'options must be a Hash' unless options.is_a?(Hash)
69
+
70
+ effective = NEW_DEFAULTS.merge(options)
71
+
72
+ # `:initialize` must resolve to one of the allowed "on" values.
73
+ # Catches `false`, `nil`, garbage values like `'wrong'`, etc. —
74
+ # without it the returned class has no hash constructor and the
75
+ # next `klass.new(hash)` raises an opaque `ArgumentError` from
76
+ # `Object#initialize`.
77
+ unless [true, :strict].include?(effective[:initialize])
78
+ raise ArgumentError,
79
+ '`Micro::Attributes.new` requires the :initialize feature ' \
80
+ '(omit the key or pass `true` / `:strict`)'
81
+ end
82
+
83
+ klass = Class.new
84
+ klass.send(:include, with(effective))
85
+ klass.class_eval(&block) if block
86
+
87
+ # ActiveModel-aware naming for the anonymous factory class. Without
88
+ # this, the first call to `errors.full_messages` raises "Class name
89
+ # cannot be blank" because AM's `model_name` is invoked on a still-
90
+ # anonymous class (the user may never assign the result to a
91
+ # constant). `self.name` resolves lazily — Ruby fills it in if/when
92
+ # the constant is assigned later. Mirrors the inline-child fix in
93
+ # `Macros#__micro_attributes_build_inline_class__`.
94
+ if defined?(::ActiveModel::Validations) && klass.include?(::ActiveModel::Validations)
95
+ klass.define_singleton_method(:model_name) do
96
+ ::ActiveModel::Name.new(self, nil, self.name || self.inspect)
97
+ end
98
+ end
99
+
100
+ klass
101
+ end
102
+
41
103
  def attribute?(name, include_all = false)
42
104
  self.class.attribute?(name, include_all)
43
105
  end
@@ -147,13 +209,21 @@ module Micro
147
209
 
148
210
  def __attributes_assign(hash)
149
211
  self.class.__attributes_data__.each do |name, attribute_data|
150
- __attribute_assign(name, hash, attribute_data) if attribute?(name, true)
212
+ ___attribute_assign(name, hash, attribute_data) if attribute?(name, true)
151
213
  end
152
214
 
153
215
  __attributes.freeze
154
216
  end
155
217
 
156
- def __attribute_assign(name, init_hash, attribute_data)
218
+ # Renamed from `__attribute_assign` (3 underscores) to put the per-attribute
219
+ # instance-level assignment hook deeper into the "internal" naming convention
220
+ # than the class-method macro of the same prior name in `Macros`. Coercion is
221
+ # prepended to every host class, so this method's MRO position is now ahead of
222
+ # the host class itself — a user who defined `def __attribute_assign(...)`
223
+ # directly on their class would have been silently intercepted. The 3-underscore
224
+ # name is intentionally less inviting and avoids any conceivable collision with
225
+ # the class-method version still named `__attribute_assign` in `Macros`.
226
+ def ___attribute_assign(name, init_hash, attribute_data)
157
227
  value_to_assign = FetchValueToAssign.(init_hash, init_hash[name], attribute_data)
158
228
 
159
229
  ivar_value = instance_variable_set("@#{name}", value_to_assign)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: u-attributes
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.2
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Serradura
@@ -97,6 +97,7 @@ files:
97
97
  - gemfiles/rails_8_1.gemfile
98
98
  - gemfiles/rails_edge.gemfile
99
99
  - lib/micro/attributes.rb
100
+ - lib/micro/attributes/composition.rb
100
101
  - lib/micro/attributes/diff.rb
101
102
  - lib/micro/attributes/features.rb
102
103
  - lib/micro/attributes/features/accept.rb