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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/Gemfile +1 -1
- data/README.md +643 -634
- data/gemfiles/rails_8_1.gemfile +4 -2
- data/gemfiles/rails_edge.gemfile +4 -2
- data/lib/micro/attributes/composition.rb +108 -0
- data/lib/micro/attributes/features/accept.rb +1 -1
- data/lib/micro/attributes/features/activemodel_validations.rb +8 -0
- data/lib/micro/attributes/features.rb +68 -1
- data/lib/micro/attributes/macros.rb +187 -5
- data/lib/micro/attributes/version.rb +1 -1
- data/lib/micro/attributes.rb +72 -2
- metadata +2 -1
data/gemfiles/rails_8_1.gemfile
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
source "https://rubygems.org"
|
|
4
4
|
|
|
5
5
|
gem "rake", "~> 13.0"
|
|
6
|
-
gem "u-case", "~>
|
|
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
|
|
data/gemfiles/rails_edge.gemfile
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
source "https://rubygems.org"
|
|
4
4
|
|
|
5
5
|
gem "rake", "~> 13.0"
|
|
6
|
-
gem "u-case", "~>
|
|
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
|
|
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).
|
|
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] ||
|
|
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
|
-
|
|
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
|
|
data/lib/micro/attributes.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|