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.
data/README.md CHANGED
@@ -13,60 +13,127 @@
13
13
  </p>
14
14
  </p>
15
15
 
16
- This gem allows you to define "immutable" objects, when using it your objects will only have getters and no setters.
17
- So, if you change [[1](#with_attribute)] [[2](#with_attributes)] an attribute of the object, you’ll have a new object instance. That is, you transform the object instead of modifying it.
16
+ `u-attributes` lets you define classes whose instances expose attribute readers but no setters. Mutation goes through [`#with_attribute`](#with_attribute) / [`#with_attributes`](#with_attributes), which return a new instance you transform the object instead of modifying it.
17
+
18
+ ## Why u-attributes? <!-- omit in toc -->
19
+
20
+ - **Lighter than `ActiveModel::Attributes`** — no Rails dependency required; ActiveModel integration is opt-in.
21
+ - **More than `Struct`** — defaults (including callable ones), required keys, value validation (`accept:` / `reject:`), per-attribute visibility, freezing, immutable updates, and deep composition.
22
+ - **Opt-in surface** — start with `include Micro::Attributes`; layer `:diff`, `:accept`, `:initialize`, `:keys_as_symbol`, and ActiveModel validations only when needed.
23
+ - **Composes recursively** — nested children via `accept:` or inline blocks; hashes auto-coerce, and validation errors bubble up the tree (see [Composition](#composition)).
24
+
25
+ ## Quick start <!-- omit in toc -->
26
+
27
+ ```ruby
28
+ require 'u-attributes'
29
+
30
+ class Person
31
+ include Micro::Attributes.with(:initialize)
32
+
33
+ attribute :name, default: 'Anonymous'
34
+ attribute :age, required: true
35
+ end
36
+
37
+ person = Person.new(age: 21)
38
+ person.name # "Anonymous"
39
+ person.age # 21
40
+
41
+ older = person.with_attribute(:age, 22)
42
+ older.age # 22
43
+ older.equal?(person) # false — a new instance
44
+
45
+ # There are no setters.
46
+ person.name = 'X' # NoMethodError
47
+ ```
48
+
49
+ Prefer a one-liner? [`Micro::Attributes.new`](#microattributesnew) returns a fresh class with `:initialize` and `:accept` enabled by preset:
50
+
51
+ ```ruby
52
+ Person = Micro::Attributes.new do
53
+ attribute :name, default: 'Anonymous'
54
+ attribute :age, required: true
55
+ end
56
+ ```
57
+
58
+ Need nested attributes? Define them inline with a [block](#defining-nested-attributes-inline-block-form) — child blocks inherit the host's feature mix and [compose to any depth](#deep-nesting--validation-bubbling):
59
+
60
+ ```ruby
61
+ Order = Micro::Attributes.new do
62
+ attribute :id, accept: Integer
63
+
64
+ attribute :customer do
65
+ attribute :name, accept: String
66
+ attribute :email, accept: String
67
+ end
68
+ end
69
+
70
+ order = Order.new(id: 1, customer: { name: 'Rodrigo', email: 'rodrigo@example.com' })
71
+ order.customer.name # "Rodrigo"
72
+ ```
73
+
74
+ See [Feature overview](#feature-overview) for what else the gem can do.
18
75
 
19
76
  ## Documentation <!-- omit in toc -->
20
77
 
21
- Version | Documentation
22
- ---------- | -------------
23
- unreleased | https://github.com/serradura/u-attributes/blob/main/README.md
24
- 3.0.2 | https://github.com/serradura/u-attributes/blob/v3.x/README.md
25
- 2.8.0 | https://github.com/serradura/u-attributes/blob/v2.x/README.md
78
+ | Version | Documentation |
79
+ | ---------- | ------------------------------------------------------------- |
80
+ | unreleased | https://github.com/serradura/u-attributes/blob/main/README.md |
81
+ | 3.1.0 | https://github.com/serradura/u-attributes/blob/v3.x/README.md |
82
+ | 2.8.0 | https://github.com/serradura/u-attributes/blob/v2.x/README.md |
26
83
 
27
84
  # Table of contents <!-- omit in toc -->
85
+
28
86
  - [Installation](#installation)
29
87
  - [Compatibility](#compatibility)
30
- - [Usage](#usage)
31
- - [How to define attributes?](#how-to-define-attributes)
32
- - [`Micro::Attributes#attributes=`](#microattributesattributes)
33
- - [How to extract attributes from an object or hash?](#how-to-extract-attributes-from-an-object-or-hash)
34
- - [Is it possible to define an attribute as required?](#is-it-possible-to-define-an-attribute-as-required)
35
- - [`Micro::Attributes#attribute`](#microattributesattribute)
36
- - [`Micro::Attributes#attribute!`](#microattributesattribute-1)
37
- - [Attribute visibility (`private:`, `protected:`)](#attribute-visibility-private-protected)
38
- - [Freezing attribute values (`freeze:`)](#freezing-attribute-values-freeze)
39
- - [How to define multiple attributes?](#how-to-define-multiple-attributes)
40
- - [`Micro::Attributes.with(:initialize)`](#microattributeswithinitialize)
41
- - [`#with_attribute()`](#with_attribute)
42
- - [`#with_attributes()`](#with_attributes)
43
- - [Defining default values to the attributes](#defining-default-values-to-the-attributes)
44
- - [The strict initializer](#the-strict-initializer)
45
- - [Is it possible to inherit the attributes?](#is-it-possible-to-inherit-the-attributes)
46
- - [`.attribute!()`](#attribute)
47
- - [How to query the attributes?](#how-to-query-the-attributes)
48
- - [`.attributes`](#attributes)
49
- - [`.attribute?()`](#attribute-1)
50
- - [`#attribute?()`](#attribute-2)
51
- - [`#attributes()`](#attributes-1)
52
- - [`#attributes(keys_as:)`](#attributeskeys_as)
53
- - [`#attributes(*names)`](#attributesnames)
54
- - [`#attributes([names])`](#attributesnames-1)
55
- - [`#attributes(with:, without:)`](#attributeswith-without)
56
- - [`#defined_attributes`](#defined_attributes)
57
- - [Built-in extensions](#built-in-extensions)
58
- - [Picking specific features](#picking-specific-features)
59
- - [`Micro::Attributes.with`](#microattributeswith)
60
- - [`Micro::Attributes.without`](#microattributeswithout)
61
- - [Picking all the features](#picking-all-the-features)
62
- - [Extensions](#extensions)
63
- - [Accept extension](#accept-extension)
64
- - [`ActiveModel::Validation` extension](#activemodelvalidation-extension)
65
- - [`.attribute()` options](#attribute-options)
66
- - [Diff extension](#diff-extension)
67
- - [Initialize extension](#initialize-extension)
68
- - [Strict mode](#strict-mode)
69
- - [Keys as symbol extension](#keys-as-symbol-extension)
88
+ - [Feature overview](#feature-overview)
89
+ - [What you get by default](#what-you-get-by-default)
90
+ - [Opt-in extensions](#opt-in-extensions)
91
+ - [Picking a combination](#picking-a-combination)
92
+ - [Defining attributes](#defining-attributes)
93
+ - [`attribute` and `attributes`](#attribute-and-attributes)
94
+ - [Defaults](#defaults)
95
+ - [Required attributes](#required-attributes)
96
+ - [Visibility (`private:`, `protected:`)](#visibility-private-protected)
97
+ - [Freezing values (`freeze:`)](#freezing-values-freeze)
98
+ - [Inheritance and `.attribute!`](#inheritance-and-attribute)
99
+ - [The initialize extension](#the-initialize-extension)
100
+ - [Standard mode](#standard-mode)
101
+ - [Strict mode](#strict-mode)
102
+ - [`#with_attribute()`](#with_attribute)
103
+ - [`#with_attributes()`](#with_attributes)
104
+ - [Extracting attributes from another object or hash](#extracting-attributes-from-another-object-or-hash)
105
+ - [Writing your own constructor](#writing-your-own-constructor)
106
+ - [Reading attributes](#reading-attributes)
107
+ - [Class-level: `.attributes`, `.attribute?`](#class-level-attributes-attribute)
108
+ - [Instance-level: `#attribute`, `#attribute!`, `#attribute?`](#instance-level-attribute-attribute-attribute)
109
+ - [The `#attributes` hash](#the-attributes-hash)
110
+ - [`keys_as:` — control key type](#keys_as--control-key-type)
111
+ - [Slicing with `*names` or `[names]`](#slicing-with-names-or-names)
112
+ - [`with:` and `without:`](#with-and-without)
113
+ - [`#defined_attributes`](#defined_attributes)
114
+ - [Other extensions](#other-extensions)
115
+ - [Accept extension](#accept-extension)
116
+ - [What can `accept:` / `reject:` receive?](#what-can-accept--reject-receive)
117
+ - [`allow_nil:` option](#allow_nil-option)
118
+ - [`rejection_message:` option](#rejection_message-option)
119
+ - [Strict mode (`accept: :strict`)](#strict-mode-accept-strict)
120
+ - [Interaction with other features](#interaction-with-other-features)
121
+ - [ActiveModel validations extension](#activemodel-validations-extension)
122
+ - [Diff extension](#diff-extension)
123
+ - [Keys as Symbol extension](#keys-as-symbol-extension)
124
+ - [Composition](#composition)
125
+ - [`Micro::Attributes.new`](#microattributesnew)
126
+ - [Enabling extensions](#enabling-extensions)
127
+ - [Nested attributes via `accept:`](#nested-attributes-via-accept)
128
+ - [Defining nested attributes inline (block form)](#defining-nested-attributes-inline-block-form)
129
+ - [Per-block extensions](#per-block-extensions)
130
+ - [Deep nesting \& validation bubbling](#deep-nesting--validation-bubbling)
131
+ - [Accept-error bubbling (no ActiveModel needed)](#accept-error-bubbling-no-activemodel-needed)
132
+ - [ActiveModel deep validation](#activemodel-deep-validation)
133
+ - [Notes](#notes)
134
+ - [What "immutable" means here (and thread safety)](#what-immutable-means-here-and-thread-safety)
135
+ - [Unknown keys in `extract_attributes_from`](#unknown-keys-in-extract_attributes_from)
136
+ - [Migrating from 2.x](#migrating-from-2x)
70
137
  - [Development](#development)
71
138
  - [Contributing](#contributing)
72
139
  - [License](#license)
@@ -82,16 +149,16 @@ gem 'u-attributes', '~> 3.0'
82
149
 
83
150
  # Compatibility
84
151
 
85
- | u-attributes | branch | ruby | activemodel |
86
- | ---------------- | ------ | -------- | -------------- |
87
- | unreleased | main | >= 2.7 | >= 6.0 |
88
- | 3.0.2 | v3.x | >= 2.7 | >= 6.0 |
89
- | 2.8.0 | v2.x | >= 2.2.0 | >= 3.2, <= 8.1 |
152
+ | u-attributes | branch | ruby | activemodel |
153
+ | ------------ | ------ | -------- | -------------- |
154
+ | unreleased | main | >= 2.7 | >= 6.0 |
155
+ | 3.1.0 | v3.x | >= 2.7 | >= 6.0 |
156
+ | 2.8.0 | v2.x | >= 2.2.0 | >= 3.2, <= 8.1 |
90
157
 
91
158
  This library is tested (CI matrix) against:
92
159
 
93
160
  | Ruby / Rails | 6.0 | 6.1 | 7.0 | 7.1 | 7.2 | 8.0 | 8.1 | Edge |
94
- |--------------|-----|-----|-----|-----|-----|-----|-----|------|
161
+ | ------------ | --- | --- | --- | --- | --- | --- | --- | ---- |
95
162
  | 2.7 | ✅ | ✅ | ✅ | ✅ | | | | |
96
163
  | 3.0 | ✅ | ✅ | ✅ | ✅ | | | | |
97
164
  | 3.1 | | | ✅ | ✅ | ✅ | | | |
@@ -101,172 +168,150 @@ This library is tested (CI matrix) against:
101
168
  | 4.x | | | | | | | ✅ | ✅ |
102
169
  | Head | | | | | | | ✅ | ✅ |
103
170
 
104
- > **Note**: The activemodel is an optional dependency, this module [can be enabled](#activemodelvalidation-extension) to validate the attributes.
105
-
106
- [⬆️ Back to Top](#table-of-contents-)
107
-
108
- # Usage
171
+ > **Note:** `activemodel` is an optional dependency; the [ActiveModel validations extension](#activemodel-validations-extension) needs it.
109
172
 
110
- ## How to define attributes?
173
+ # Feature overview
111
174
 
112
- By default, you must define the class constructor.
113
-
114
- ```ruby
115
- class Person
116
- include Micro::Attributes
175
+ `u-attributes` is a small core (`include Micro::Attributes`) plus opt-in extensions. The two tables below map every feature in the gem so you can scan what's possible before diving into details.
117
176
 
118
- attribute :age
119
- attribute :name
177
+ ## What you get by default
120
178
 
121
- def initialize(name: 'John Doe', age:)
122
- @name, @age = name, age
123
- end
124
- end
179
+ Everything in this table is available the moment you `include Micro::Attributes` — no `.with(...)` required.
125
180
 
126
- person = Person.new(age: 21)
181
+ | Capability | Example | Notes |
182
+ | ------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
183
+ | Define an attribute | `attribute :name` | Public reader; no setter |
184
+ | Define many at once | `attributes :a, :b, default: 0` | Trailing options apply to every name |
185
+ | Override in a subclass | `attribute! :name, default: 'X'` | Subclass-only |
186
+ | Default value | `attribute :name, default: 'X'` | Static value or `proc { ... }` / `->(v) { ... }` |
187
+ | Required (without strict) | `attribute :name, required: true` | Raises on missing key if `attributes=` is invoked with one |
188
+ | Freeze the value | `attribute :name, freeze: true` | Also `:after_dup`, `:after_clone` |
189
+ | Visibility | `attribute :secret, private: true` | Or `protected: true`; hidden from `#attributes` hash |
190
+ | Layer extensions inline | `with :keys_as_symbol` | Class macro — see [Opt-in extensions](#opt-in-extensions) |
191
+ | Block-form nested | `attribute :foo do ... end` | Anonymous inline class; inherits the host's feature mix |
192
+ | Hash → child coercion | `attribute :child, accept: Other` | When `Other` includes `Micro::Attributes`, a hash auto-builds an instance |
193
+ | Deep-error bubble marker | `parent.attributes_errors['child']` | Descendant errors mirror up as `'is invalid'` (requires `:accept` on the parent so the error hash exists) |
194
+ | Struct-style factory | `User = Micro::Attributes.new { attribute :name }` | Returns a class; preset is `initialize: true, accept: true` |
127
195
 
128
- person.age # 21
129
- person.name # John Doe
196
+ ## Opt-in extensions
130
197
 
131
- # By design the attributes are always exposed as reader methods (getters).
132
- # If you try to call a setter you will see a NoMethodError.
133
- #
134
- # person.name = 'Rodrigo'
135
- # NoMethodError (undefined method `name=' for #<Person:0x0000... @name='John Doe', @age=21>)
136
- ```
198
+ Mix any combination via `Micro::Attributes.with(...)` hash-style and positional-symbol APIs both work and can be combined.
137
199
 
138
- [⬆️ Back to Top](#table-of-contents-)
200
+ | Extension | Hash API | Positional API | What it adds |
201
+ | --------------------------- | ---------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
202
+ | **Initialize** | `initialize: true` | `:initialize` | Auto-generated `new(hash)` constructor + immutable `#with_attribute(s)` |
203
+ | **Initialize (strict)** | `initialize: :strict` | (hash only) | All attributes without a default become **required**; missing keys raise `ArgumentError`. Implies `Initialize`. |
204
+ | **Accept** | `accept: true` | `:accept` | `accept:` / `reject:` / `allow_nil:` / `rejection_message:` validation; `#attributes_errors`, `#attributes_errors?`, `#accepted_attributes`, `#rejected_attributes` |
205
+ | **Accept (strict)** | `accept: :strict` | (hash only) | Any rejection raises `ArgumentError` immediately. Implies `Accept`. |
206
+ | **Diff** | `diff: true` | `:diff` | `#diff_attributes(other)` returns a `Diff::Changes` (`#changed?`, `#differences`, etc.) |
207
+ | **Keys as Symbol** | `keys_as: :symbol` | `:keys_as_symbol` | Symbol-keyed storage; disables indifferent access for performance/strictness |
208
+ | **ActiveModel Validations** | `active_model: :validations` | `:activemodel_validations` | Mixes `ActiveModel::Validations` (`valid?`, `errors`, `validates :x, presence: true`, the `validates:` / `validate:` attribute options); `parent.valid?` bubbles **deep** descendant invalidity into `errors`. Requires the `activemodel` gem. |
139
209
 
140
- ### `Micro::Attributes#attributes=`
210
+ ## Picking a combination
141
211
 
142
- This is a protected method to make easier the assignment in a constructor. e.g.
212
+ Two equivalent ways to enable features:
143
213
 
144
214
  ```ruby
145
- class Person
146
- include Micro::Attributes
147
-
148
- attribute :age
149
- attribute :name, default: 'John Doe'
150
-
151
- def initialize(options)
152
- self.attributes = options
153
- end
154
- end
155
-
156
- person = Person.new(age: 20)
215
+ # Hash style — self-documenting; great when you're enabling several
216
+ Micro::Attributes.with(
217
+ diff: true,
218
+ accept: true, # :strict,
219
+ keys_as: :symbol, # :string | :indifferent (default),
220
+ initialize: true, # :strict,
221
+ active_model: :validations
222
+ )
157
223
 
158
- person.age # 20
159
- person.name # John Doe
224
+ # Positional style — terser when you're just turning things on
225
+ include Micro::Attributes.with(:initialize, :accept, :diff, :keys_as_symbol)
160
226
  ```
161
227
 
162
- #### How to extract attributes from an object or hash?
163
-
164
- You can extract attributes using the `extract_attributes_from` method. For each attribute name it
165
- will first call the reader method (`object.attribute_key`) when available, and fall back to the
166
- hash accessor (`object[attribute_key]`) otherwise. The reader method has priority because it lets
167
- the source object encapsulate any computed/derived value.
168
-
169
- ```ruby
170
- class Person
171
- include Micro::Attributes
172
-
173
- attribute :age
174
- attribute :name, default: 'John Doe'
228
+ Rules:
175
229
 
176
- def initialize(user:)
177
- self.attributes = extract_attributes_from(user)
178
- end
179
- end
230
+ - Omit a key (or pass `false` / `nil`) to disable a feature.
231
+ - `keys_as: :string` and `keys_as: :indifferent` are no-ops (the default); only `:symbol` activates `KeysAsSymbol`.
232
+ - The two forms can be mixed in a single call: `Micro::Attributes.with(:initialize, accept: :strict)`.
233
+ - Strict variants are hash-only: `Micro::Attributes.with(initialize: :strict, accept: :strict)`.
180
234
 
181
- # extracting from an object
235
+ Calling `with` with no arguments raises:
182
236
 
183
- class User
184
- attr_accessor :age, :name
237
+ ```ruby
238
+ class Job
239
+ include Micro::Attributes.with() # ArgumentError (Invalid feature name! Available options: :accept, :activemodel_validations, :diff, :initialize, :keys_as_symbol)
185
240
  end
241
+ ```
186
242
 
187
- user = User.new
188
- user.age = 20
243
+ To enable every feature at once, use `Micro::Attributes.with_all_features` — equivalent to the full strict mix:
189
244
 
190
- person = Person.new(user: user)
245
+ ```ruby
246
+ Micro::Attributes.with_all_features
191
247
 
192
- person.age # 20
193
- person.name # John Doe
248
+ # Same as:
249
+ Micro::Attributes.with(:accept, :activemodel_validations, :diff, :keys_as_symbol, initialize: :strict)
250
+ ```
194
251
 
195
- # extracting from a hash
252
+ Use `Micro::Attributes.without(:feature, ...)` to exclude features from the full set (e.g. `Micro::Attributes.without(:diff)` loads everything except Diff).
196
253
 
197
- another_person = Person.new(user: { age: 55, name: 'Julia Not Roberts' })
254
+ The same `with(...)` is also a class macro — `class X; include Micro::Attributes.with(:initialize); with diff: true; end` layers more features on top of an existing include. It's also callable inside an `attribute :foo do ... end` block to layer features onto just that inline child; see [Per-block extensions](#per-block-extensions).
198
255
 
199
- another_person.age # 55
200
- another_person.name # Julia Not Roberts
201
- ```
256
+ [⬆️ Back to top](#-attributes)
202
257
 
203
- #### Is it possible to define an attribute as required?
258
+ # Defining attributes
204
259
 
205
- You only need to use the `required: true` option.
260
+ ## `attribute` and `attributes`
206
261
 
207
- But to this work, you need to assign the attributes using the [`#attributes=`](#microattributesattributes) method or the extensions: [initialize](#initialize-extension), [activemodel_validations](#activemodelvalidation-extension).
262
+ Use `.attribute` for a single name, and `.attributes` (plural) for several names that share the same options.
208
263
 
209
264
  ```ruby
210
265
  class Person
211
- include Micro::Attributes
212
-
213
- attribute :age
214
- attribute :name, required: true
266
+ include Micro::Attributes.with(:initialize)
215
267
 
216
- def initialize(attributes)
217
- self.attributes = attributes
218
- end
268
+ attribute :name
269
+ attributes :age, :score, default: 0
219
270
  end
220
271
 
221
- Person.new(age: 32) # ArgumentError (missing keyword: :name)
272
+ Person.new(name: 'Ada').name # "Ada"
273
+ Person.new(name: 'Ada').age # 0
222
274
  ```
223
275
 
224
- [⬆️ Back to Top](#table-of-contents-)
276
+ > `.attributes` accepts a single trailing options hash and applies it to every name in the list. If you need different options per attribute, call `.attribute` once per attribute.
225
277
 
226
- ### `Micro::Attributes#attribute`
278
+ ## Defaults
227
279
 
228
- Use this method with a valid attribute name to get its value.
280
+ Pass `default:` with either a static value or a callable.
229
281
 
230
282
  ```ruby
231
- person = Person.new(age: 20)
232
-
233
- person.attribute('age') # 20
234
- person.attribute(:name) # John Doe
235
- person.attribute('foo') # nil
236
- ```
237
-
238
- If you pass a block, it will be executed only if the attribute was valid.
283
+ class Person
284
+ include Micro::Attributes.with(:initialize)
239
285
 
240
- ```ruby
241
- person.attribute(:name) { |value| puts value } # John Doe
242
- person.attribute('age') { |value| puts value } # 20
243
- person.attribute('foo') { |value| puts value } # !! Nothing happened, because of the attribute doesn't exist.
286
+ attribute :age, default: -> v { v&.to_i }
287
+ attribute :name, default: ->(name) { String(name || 'John Doe').strip }
288
+ end
244
289
  ```
245
290
 
246
- [⬆️ Back to Top](#table-of-contents-)
291
+ If the callable takes an argument, it receives the incoming value and can transform it before assignment.
247
292
 
248
- ### `Micro::Attributes#attribute!`
293
+ ## Required attributes
249
294
 
250
- Works like the `#attribute` method, but it will raise an exception when the attribute doesn't exist.
295
+ `required: true` raises when the key is missing from the constructor:
251
296
 
252
297
  ```ruby
253
- person.attribute!('foo') # NameError (undefined attribute `foo)
298
+ class Person
299
+ include Micro::Attributes.with(:initialize)
300
+
301
+ attribute :age
302
+ attribute :name, required: true
303
+ end
254
304
 
255
- person.attribute!('foo') { |value| value } # NameError (undefined attribute `foo)
305
+ Person.new(age: 32) # ArgumentError (missing keyword: :name)
256
306
  ```
257
307
 
258
- [⬆️ Back to Top](#table-of-contents-)
308
+ For a class-wide version, see [Strict mode](#strict-mode) on the initialize extension — it makes every attribute without a default required.
259
309
 
260
- ### Attribute visibility (`private:`, `protected:`)
310
+ ## Visibility (`private:`, `protected:`)
261
311
 
262
- By default every attribute reader is `public`. Use the `private: true` or `protected: true`
263
- options to restrict the reader's visibility — useful for things like passwords, tokens, and any
264
- internal value you don't want to expose on the public API.
312
+ By default every attribute reader is `public`. Use `private: true` or `protected: true` to restrict it — useful for things like passwords, tokens, and any internal value you don't want to expose on the public API.
265
313
 
266
- Private/protected attributes are also excluded from the public attribute set (`#attributes`,
267
- `.attributes`, `#attribute?`), so they don't leak through serialization or enumeration. To check
268
- or fetch them explicitly, pass `true` as the second argument to `#attribute?` (or use
269
- `#attribute!`).
314
+ Private/protected attributes are also excluded from the public attribute set (`#attributes`, `.attributes`, `#attribute?`), so they don't leak through serialization or enumeration. To check or fetch them explicitly, pass `true` as the second argument to `#attribute?` (or use `#attribute!`).
270
315
 
271
316
  ```ruby
272
317
  require 'digest'
@@ -276,7 +321,8 @@ class User::SignUpParams
276
321
 
277
322
  TrimString = ->(value) { String(value).strip }
278
323
 
279
- attribute :email, default: TrimString
324
+ attribute :email, default: TrimString
325
+
280
326
  attributes :password, :password_confirmation, default: TrimString, private: true
281
327
 
282
328
  def password_digest
@@ -295,37 +341,32 @@ user = User::SignUpParams.new(
295
341
  password_confirmation: 'secret'
296
342
  )
297
343
 
298
- user.attributes # { "email" => "email@example.com" }
344
+ user.attributes # { "email" => "email@example.com" }
299
345
 
300
- user.attribute?('email') # true
301
- user.attribute?('password') # false (not in the public set)
346
+ user.attribute?('email') # true
347
+ user.attribute?('password') # false (not in the public set)
302
348
  user.attribute?('password', true) # true (use the second arg to look at all attributes)
303
349
 
304
- user.attribute('password') # nil (returns nil instead of leaking the value)
305
- user.attribute!('password') # NameError ("tried to access a private attribute `password")
350
+ user.attribute('password') # nil (returns nil instead of leaking the value)
351
+ user.attribute!('password') # NameError ("tried to access a private attribute `password")
306
352
 
307
- user.password # NoMethodError (private method `password' called for ...)
353
+ user.password # NoMethodError (private method `password' called for ...)
308
354
  ```
309
355
 
310
356
  - `private:` and `protected:` map directly to Ruby's method-visibility semantics on the reader.
311
357
  - The visibility configuration is preserved on inheritance.
312
- - Works with the `:keys_as_symbol` extension (`attributes_by_visibility` will return the keys in
313
- the configured type).
314
-
315
- The class-level `attributes_by_visibility` method returns a hash with `:public`, `:private`, and
316
- `:protected` keys so you can introspect how each attribute was declared.
358
+ - Works with the `:keys_as_symbol` extension (`attributes_by_visibility` will return the keys in the configured type).
317
359
 
318
- [⬆️ Back to Top](#table-of-contents-)
360
+ The class-level `attributes_by_visibility` method returns a hash with `:public`, `:private`, and `:protected` keys so you can introspect how each attribute was declared.
319
361
 
320
- ### Freezing attribute values (`freeze:`)
362
+ ## Freezing values (`freeze:`)
321
363
 
322
- Use the `freeze:` option to make sure the value stored in the attribute can't be mutated after
323
- the object is built. Three modes are supported:
364
+ Use `freeze:` to make sure the value stored in the attribute can't be mutated after the object is built. Three modes are supported:
324
365
 
325
- | Value | Behavior |
326
- | -------------- | --------------------------------------------------------------------- |
327
- | `true` | Calls `value.freeze` on the incoming value. The original is frozen. |
328
- | `:after_dup` | `value.dup.freeze` — freezes a shallow copy; the original stays free. |
366
+ | Value | Behavior |
367
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------ |
368
+ | `true` | Calls `value.freeze` on the incoming value. The original is frozen. |
369
+ | `:after_dup` | `value.dup.freeze` — freezes a shallow copy; the original stays free. |
329
370
  | `:after_clone` | `value.clone.freeze` — same as above but uses `#clone` (preserves singleton methods, frozen state, tainted state, etc.). |
330
371
 
331
372
  ```ruby
@@ -337,121 +378,140 @@ class Person
337
378
  attribute :payload, freeze: :after_clone
338
379
  end
339
380
 
340
- raw_name = +"Rodrigo"
381
+ raw_name = +"Rodrigo"
382
+ raw_address = +"Av. Paulista"
341
383
 
342
384
  person = Person.new(
343
385
  name: raw_name,
344
- address: 'Av. Paulista',
386
+ address: raw_address,
345
387
  payload: { id: 1 }
346
388
  )
347
389
 
348
- person.name.frozen? # true
349
- raw_name.frozen? # true -> freeze: true mutates the original
390
+ person.name.frozen? # true
391
+ raw_name.frozen? # true freeze: true mutates the original
350
392
 
351
- person.address.frozen? # true
352
- 'Av. Paulista'.frozen? # depends on the source string; the duplicate is what's frozen
393
+ person.address.frozen? # true
394
+ raw_address.frozen? # false ← :after_dup freezes only the copy
353
395
  ```
354
396
 
355
- `freeze:` is applied after the default value resolution, so the frozen value reflects whatever
356
- the attribute ends up holding (raw value, default, or callable-default result).
397
+ `freeze:` is applied **after** the default value resolution, so the frozen value reflects whatever the attribute ends up holding (raw value, default, or callable-default result).
357
398
 
358
- [⬆️ Back to Top](#table-of-contents-)
399
+ ## Inheritance and `.attribute!`
359
400
 
360
- ## How to define multiple attributes?
361
-
362
- Use `.attributes` with a list of attribute names.
401
+ Attribute definitions are preserved through inheritance:
363
402
 
364
403
  ```ruby
365
404
  class Person
366
- include Micro::Attributes
405
+ include Micro::Attributes.with(:initialize)
367
406
 
368
- attributes :age, :name
407
+ attribute :age
408
+ attribute :name, default: 'John Doe'
409
+ end
369
410
 
370
- def initialize(options)
371
- self.attributes = options
372
- end
411
+ class Subclass < Person
412
+ attribute :foo
373
413
  end
374
414
 
375
- person = Person.new(age: 32)
415
+ instance = Subclass.new({})
376
416
 
377
- person.name # nil
378
- person.age # 32
417
+ instance.name # "John Doe"
418
+ instance.respond_to?(:age) # true
419
+ instance.respond_to?(:foo) # true
379
420
  ```
380
421
 
381
- You can also pass a trailing options hash and every attribute in the list will be declared with
382
- those options. This is the canonical way to declare several attributes that share the same
383
- configuration (default value, visibility, freezing, validations, etc.).
422
+ Use `.attribute!` to override an inherited attribute's options (e.g. its default):
384
423
 
385
424
  ```ruby
386
- class User::SignUpParams
387
- include Micro::Attributes.with(:initialize, :accept)
425
+ class AnotherSubclass < Person
426
+ attribute! :name, default: 'Alfa'
427
+ end
388
428
 
389
- TrimString = ->(value) { String(value).strip }
429
+ AnotherSubclass.new({}).name # "Alfa"
390
430
 
391
- attribute :email, default: TrimString
392
- attributes :password, :password_confirmation, reject: :empty?, default: TrimString, private: true
431
+ class SubSubclass < Subclass
432
+ attribute! :age, default: 0
433
+ attribute! :name, default: 'Beta'
393
434
  end
435
+
436
+ SubSubclass.new({}).name # "Beta"
437
+ SubSubclass.new({}).age # 0
394
438
  ```
395
439
 
396
- > **Note:** Unlike `.attribute`, this method accepts a shared options hash but defines all listed
397
- > attributes with the same configuration. If you need different defaults/options per attribute,
398
- > use `#attribute()` once per attribute.
440
+ [⬆️ Back to top](#-attributes)
399
441
 
400
- [⬆️ Back to Top](#table-of-contents-)
442
+ # The initialize extension
401
443
 
402
- ## `Micro::Attributes.with(:initialize)`
444
+ `Micro::Attributes.with(:initialize)` generates a hash-keyword constructor plus the immutable update methods `#with_attribute` / `#with_attributes`. It's the path most projects want.
403
445
 
404
- Use `Micro::Attributes.with(:initialize)` to define a constructor to assign the attributes. e.g.
446
+ ## Standard mode
405
447
 
406
448
  ```ruby
407
449
  class Person
408
450
  include Micro::Attributes.with(:initialize)
409
451
 
410
- attribute :age, required: true
452
+ attribute :age, required: true
411
453
  attribute :name, default: 'John Doe'
412
454
  end
413
455
 
414
456
  person = Person.new(age: 18)
415
-
416
457
  person.age # 18
417
- person.name # John Doe
458
+ person.name # "John Doe"
459
+ ```
460
+
461
+ ## Strict mode
462
+
463
+ `Micro::Attributes.with(initialize: :strict)` forbids instantiation without every attribute keyword — equivalent to declaring every attribute without a default as `required: true`.
464
+
465
+ ```ruby
466
+ class StrictPerson
467
+ include Micro::Attributes.with(initialize: :strict)
468
+
469
+ attribute :age
470
+ attribute :name, default: 'John Doe'
471
+ end
472
+
473
+ StrictPerson.new({}) # ArgumentError (missing keyword: :age)
474
+
475
+ # Attributes with a default may still be omitted:
476
+ StrictPerson.new(age: nil).age # nil
477
+ StrictPerson.new(age: nil).name # "John Doe"
418
478
  ```
419
479
 
420
- This extension enables two methods for your objects.
421
- The `#with_attribute()` and `#with_attributes()`.
480
+ Aside from that validation, strict mode behaves identically to the standard mode.
481
+
482
+ ## `#with_attribute()`
422
483
 
423
- ### `#with_attribute()`
484
+ Returns a new instance with one attribute updated. The original is untouched.
424
485
 
425
486
  ```ruby
426
487
  another_person = person.with_attribute(:age, 21)
427
488
 
428
489
  another_person.age # 21
429
- another_person.name # John Doe
490
+ another_person.name # "John Doe"
430
491
  another_person.equal?(person) # false
431
492
  ```
432
493
 
433
- ### `#with_attributes()`
494
+ ## `#with_attributes()`
495
+
496
+ Returns a new instance with multiple attributes updated.
434
497
 
435
- Use it to assign multiple attributes
436
498
  ```ruby
437
499
  other_person = person.with_attributes(name: 'Serradura', age: 32)
438
500
 
439
501
  other_person.age # 32
440
- other_person.name # Serradura
502
+ other_person.name # "Serradura"
441
503
  other_person.equal?(person) # false
442
504
  ```
443
505
 
444
- If you pass a value different of a Hash, a Kind::Error will be raised.
506
+ Passing a non-Hash raises:
445
507
 
446
508
  ```ruby
447
- Person.new(1) # Kind::Error (1 expected to be a kind of Hash)
509
+ person.with_attributes(1) # Kind::Error (1 expected to be a kind of Hash)
448
510
  ```
449
511
 
450
- [⬆️ Back to Top](#table-of-contents-)
512
+ ## Extracting attributes from another object or hash
451
513
 
452
- ## Defining default values to the attributes
453
-
454
- To do this, you only need make use of the `default:` keyword. e.g.
514
+ `extract_attributes_from` reads only the declared attribute names from any source — object or hash. For each name it first calls the reader method (`source.attribute_key`) when available and falls back to hash access (`source[attribute_key]`) otherwise. The reader has priority so the source object can expose a computed or derived value.
455
515
 
456
516
  ```ruby
457
517
  class Person
@@ -459,343 +519,166 @@ class Person
459
519
 
460
520
  attribute :age
461
521
  attribute :name, default: 'John Doe'
462
- end
463
- ```
464
522
 
465
- There are two different strategies to define default values.
466
- 1. Pass a regular object, like in the previous example.
467
- 2. Pass a `proc`/`lambda`, and if it has an argument you will receive the attribute value to do something before assign it.
468
-
469
- ```ruby
470
- class Person
471
- include Micro::Attributes.with(:initialize)
472
-
473
- attribute :age, default: -> age { age&.to_i }
474
- attribute :name, default: -> name { String(name || 'John Doe').strip }
523
+ def self.from(source)
524
+ new(extract_attributes_from(source))
525
+ end
475
526
  end
476
- ```
477
-
478
- [⬆️ Back to Top](#table-of-contents-)
479
-
480
- ## The strict initializer
481
-
482
- Use `.with(initialize: :strict)` to forbids an instantiation without all the attribute keywords.
483
-
484
- In other words, it is equivalent to you define all the attributes using the [`required: true` option](#is-it-possible-to-define-an-attribute-as-required).
485
-
486
- ```ruby
487
- class StrictPerson
488
- include Micro::Attributes.with(initialize: :strict)
489
527
 
490
- attribute :age
491
- attribute :name, default: 'John Doe'
528
+ # From an object:
529
+ class User
530
+ attr_accessor :age, :name
492
531
  end
532
+ user = User.new
533
+ user.age = 20
534
+ user.name = 'Alice'
493
535
 
494
- StrictPerson.new({}) # ArgumentError (missing keyword: :age)
495
- ```
496
-
497
- An attribute with a default value can be omitted.
498
-
499
- ``` ruby
500
- person_without_age = StrictPerson.new(age: nil)
536
+ Person.from(user).age # 20
537
+ Person.from(user).name # "Alice"
501
538
 
502
- person_without_age.age # nil
503
- person_without_age.name # 'John Doe'
539
+ # From a hash:
540
+ Person.from(age: 55, name: 'Julia Not Roberts').age # 55
541
+ Person.from(age: 55, name: 'Julia Not Roberts').name # "Julia Not Roberts"
504
542
  ```
505
543
 
506
- > **Note:** Except for this validation the `.with(initialize: :strict)` method will works in the same ways of `.with(:initialize)`.
507
-
508
- [⬆️ Back to Top](#table-of-contents-)
544
+ Unknown keys on the source are silently ignored only declared attributes are read.
509
545
 
510
- ## Is it possible to inherit the attributes?
546
+ ## Writing your own constructor
511
547
 
512
- Yes. e.g.
548
+ If you need a custom constructor instead of `.with(:initialize)`, include the bare module and assign attributes through the protected `attributes=` setter:
513
549
 
514
550
  ```ruby
515
551
  class Person
516
- include Micro::Attributes.with(:initialize)
552
+ include Micro::Attributes
517
553
 
518
554
  attribute :age
519
555
  attribute :name, default: 'John Doe'
520
- end
521
-
522
- class Subclass < Person # Will preserve the parent class attributes
523
- attribute :foo
524
- end
525
-
526
- instance = Subclass.new({})
527
-
528
- instance.name # John Doe
529
- instance.respond_to?(:age) # true
530
- instance.respond_to?(:foo) # true
531
- ```
532
556
 
533
- [⬆️ Back to Top](#table-of-contents-)
534
-
535
- ### `.attribute!()`
536
-
537
- This method allows us to redefine the attributes default data that was defined in the parent class. e.g.
538
-
539
- ```ruby
540
- class AnotherSubclass < Person
541
- attribute! :name, default: 'Alfa'
542
- end
543
-
544
- alfa_person = AnotherSubclass.new({})
545
-
546
- alfa_person.name # 'Alfa'
547
- alfa_person.age # nil
548
-
549
- class SubSubclass < Subclass
550
- attribute! :age, default: 0
551
- attribute! :name, default: 'Beta'
557
+ def initialize(options)
558
+ self.attributes = options
559
+ end
552
560
  end
553
561
 
554
- beta_person = SubSubclass.new({})
555
-
556
- beta_person.name # 'Beta'
557
- beta_person.age # 0
562
+ Person.new(age: 20).age # 20
563
+ Person.new(age: 20).name # "John Doe"
558
564
  ```
559
565
 
560
- [⬆️ Back to Top](#table-of-contents-)
566
+ `extract_attributes_from` works here too. Note that `#with_attribute` / `#with_attributes` and the strict-mode constructor validation are only available when the `:initialize` extension is loaded.
561
567
 
562
- ## How to query the attributes?
568
+ [⬆️ Back to top](#-attributes)
563
569
 
564
- All of the methods that will be explained can be used with any of the built-in extensions.
570
+ # Reading attributes
565
571
 
566
- **PS:** We will use the class below for all of the next examples.
572
+ All of the methods below work with any feature mix. The examples use:
567
573
 
568
574
  ```ruby
569
575
  class Person
570
- include Micro::Attributes
576
+ include Micro::Attributes.with(:initialize)
571
577
 
572
578
  attribute :age
573
579
  attribute :first_name, default: 'John'
574
- attribute :last_name, default: 'Doe'
575
-
576
- def initialize(options)
577
- self.attributes = options
578
- end
580
+ attribute :last_name, default: 'Doe'
579
581
 
580
582
  def name
581
583
  "#{first_name} #{last_name}"
582
584
  end
583
585
  end
584
- ```
585
586
 
586
- ### `.attributes`
587
+ person = Person.new(age: 20)
588
+ ```
587
589
 
588
- Listing all the class attributes.
590
+ ## Class-level: `.attributes`, `.attribute?`
589
591
 
590
592
  ```ruby
591
593
  Person.attributes # ["age", "first_name", "last_name"]
592
- ```
593
594
 
594
- ### `.attribute?()`
595
-
596
- Checking the existence of some attribute.
597
-
598
- ```ruby
599
595
  Person.attribute?(:first_name) # true
600
596
  Person.attribute?('first_name') # true
601
-
602
- Person.attribute?('foo') # false
603
- Person.attribute?(:foo) # false
597
+ Person.attribute?('foo') # false
604
598
  ```
605
599
 
606
- ### `#attribute?()`
600
+ ## Instance-level: `#attribute`, `#attribute!`, `#attribute?`
607
601
 
608
- Checking the existence of some attribute in an instance.
602
+ `#attribute(name)` returns the attribute's value (or `nil` if the name isn't declared). It accepts a block that fires only when the name is valid:
609
603
 
610
604
  ```ruby
611
- person = Person.new(age: 20)
612
-
613
- person.attribute?(:name) # true
614
- person.attribute?('name') # true
605
+ person.attribute('age') # 20
606
+ person.attribute(:first_name) # "John"
607
+ person.attribute('foo') # nil
615
608
 
616
- person.attribute?('foo') # false
617
- person.attribute?(:foo) # false
609
+ person.attribute('age') { |value| puts value } # prints 20
610
+ person.attribute('foo') { |value| puts value } # nothing — name doesn't exist
618
611
  ```
619
612
 
620
- ### `#attributes()`
621
-
622
- Fetching all the attributes with their values.
613
+ `#attribute!(name)` does the same but raises on an unknown name:
623
614
 
624
615
  ```ruby
625
- person1 = Person.new(age: 20)
626
- person1.attributes # {"age"=>20, "first_name"=>"John", "last_name"=>"Doe"}
627
-
628
- person2 = Person.new(first_name: 'Rodrigo', last_name: 'Rodrigues')
629
- person2.attributes # {"age"=>nil, "first_name"=>"Rodrigo", "last_name"=>"Rodrigues"}
616
+ person.attribute!('foo') # NameError (undefined attribute `foo)
630
617
  ```
631
618
 
632
- #### `#attributes(keys_as:)`
633
-
634
- Use the `keys_as:` option with `Symbol`/`:symbol` or `String`/`:string` to transform the attributes hash keys.
619
+ `#attribute?(name)` reports whether the name is a declared, publicly visible attribute:
635
620
 
636
621
  ```ruby
637
- person1 = Person.new(age: 20)
638
- person2 = Person.new(first_name: 'Rodrigo', last_name: 'Rodrigues')
639
-
640
- person1.attributes(keys_as: Symbol) # {:age=>20, :first_name=>"John", :last_name=>"Doe"}
641
- person2.attributes(keys_as: String) # {"age"=>nil, "first_name"=>"Rodrigo", "last_name"=>"Rodrigues"}
642
-
643
- person1.attributes(keys_as: :symbol) # {:age=>20, :first_name=>"John", :last_name=>"Doe"}
644
- person2.attributes(keys_as: :string) # {"age"=>nil, "first_name"=>"Rodrigo", "last_name"=>"Rodrigues"}
622
+ person.attribute?(:first_name) # true
623
+ person.attribute?('foo') # false
645
624
  ```
646
625
 
647
- #### `#attributes(*names)`
648
-
649
- Slices the attributes to include only the given keys (in their types).
650
-
651
- ```ruby
652
- person = Person.new(age: 20)
653
-
654
- person.attributes(:age) # {:age => 20}
655
- person.attributes(:age, :first_name) # {:age => 20, :first_name => "John"}
656
- person.attributes('age', 'last_name') # {"age" => 20, "last_name" => "Doe"}
657
-
658
- person.attributes(:age, 'last_name') # {:age => 20, "last_name" => "Doe"}
659
-
660
- # You could also use the keys_as: option to ensure the same type for all of the hash keys.
661
-
662
- person.attributes(:age, 'last_name', keys_as: Symbol) # {:age=>20, :last_name=>"Doe"}
663
- ```
626
+ Pass `true` as the second argument to include private/protected attributes — see [Visibility](#visibility-private-protected).
664
627
 
665
- #### `#attributes([names])`
628
+ ## The `#attributes` hash
666
629
 
667
- As the previous example, this methods accepts a list of keys to slice the attributes.
630
+ The bare call returns every public attribute with its current value:
668
631
 
669
632
  ```ruby
670
- person = Person.new(age: 20)
671
-
672
- person.attributes([:age]) # {:age => 20}
673
- person.attributes([:age, :first_name]) # {:age => 20, :first_name => "John"}
674
- person.attributes(['age', 'last_name']) # {"age" => 20, "last_name" => "Doe"}
675
-
676
- person.attributes([:age, 'last_name']) # {:age => 20, "last_name" => "Doe"}
677
-
678
- # You could also use the keys_as: option to ensure the same type for all of the hash keys.
679
-
680
- person.attributes([:age, 'last_name'], keys_as: Symbol) # {:age=>20, :last_name=>"Doe"}
633
+ person.attributes # {"age"=>20, "first_name"=>"John", "last_name"=>"Doe"}
681
634
  ```
682
635
 
683
- #### `#attributes(with:, without:)`
636
+ ### `keys_as:` — control key type
684
637
 
685
- Use the `with:` option to include any method value of the instance inside of the hash, and,
686
- you can use the `without:` option to exclude one or more attribute keys from the final hash.
638
+ Pass `keys_as: Symbol`/`:symbol` or `String`/`:string` to coerce hash keys:
687
639
 
688
640
  ```ruby
689
- person = Person.new(age: 20)
690
-
691
- person.attributes(without: :age) # {"first_name"=>"John", "last_name"=>"Doe"}
692
- person.attributes(without: [:age, :last_name]) # {"first_name"=>"John"}
693
-
694
- person.attributes(with: [:name], without: [:first_name, :last_name]) # {"age"=>20, "name"=>"John Doe"}
695
-
696
- # To achieves the same output of the previous example, use the attribute names to slice only them.
697
-
698
- person.attributes(:age, with: [:name]) # {:age=>20, "name"=>"John Doe"}
699
-
700
- # You could also use the keys_as: option to ensure the same type for all of the hash keys.
701
-
702
- person.attributes(:age, with: [:name], keys_as: Symbol) # {:age=>20, :name=>"John Doe"}
703
- ```
704
-
705
- ### `#defined_attributes`
706
-
707
- Listing all the available attributes.
708
-
709
- ```ruby
710
- person = Person.new(age: 20)
711
-
712
- person.defined_attributes # ["age", "first_name", "last_name"]
641
+ person.attributes(keys_as: Symbol) # {:age=>20, :first_name=>"John", :last_name=>"Doe"}
642
+ person.attributes(keys_as: :string) # {"age"=>20, "first_name"=>"John", "last_name"=>"Doe"}
713
643
  ```
714
644
 
715
- [⬆️ Back to Top](#table-of-contents-)
716
-
717
- # Built-in extensions
718
-
719
- You can use the method `Micro::Attributes.with()` to combine and require only the features that better fit your needs.
720
-
721
- But, if you desire except one or more features, use the `Micro::Attributes.without()` method.
645
+ ### Slicing with `*names` or `[names]`
722
646
 
723
- ## Picking specific features
724
-
725
- ### `Micro::Attributes.with`
647
+ Both shapes accept a list of attribute names and slice the hash. Key type is preserved per argument unless `keys_as:` is also supplied:
726
648
 
727
649
  ```ruby
728
- Micro::Attributes.with(:initialize)
729
-
730
- Micro::Attributes.with(:initialize, :keys_as_symbol)
731
-
732
- Micro::Attributes.with(:keys_as_symbol, initialize: :strict)
733
-
734
- Micro::Attributes.with(:diff, :initialize)
735
-
736
- Micro::Attributes.with(:diff, initialize: :strict)
737
-
738
- Micro::Attributes.with(:diff, :keys_as_symbol, initialize: :strict)
739
-
740
- Micro::Attributes.with(:activemodel_validations)
741
-
742
- Micro::Attributes.with(:activemodel_validations, :diff)
743
-
744
- Micro::Attributes.with(:activemodel_validations, :diff, initialize: :strict)
745
-
746
- Micro::Attributes.with(:activemodel_validations, :diff, :keys_as_symbol, initialize: :strict)
747
- ```
748
-
749
- The method `Micro::Attributes.with()` will raise an exception if no arguments/features were declared.
750
-
751
- ```ruby
752
- class Job
753
- include Micro::Attributes.with() # ArgumentError (Invalid feature name! Available options: :accept, :activemodel_validations, :diff, :initialize, :keys_as_symbol)
754
- end
650
+ person.attributes(:age, :first_name) # {:age=>20, :first_name=>"John"}
651
+ person.attributes(['age', 'last_name']) # {"age"=>20, "last_name"=>"Doe"}
652
+ person.attributes(:age, 'last_name') # {:age=>20, "last_name"=>"Doe"}
653
+ person.attributes(:age, 'last_name', keys_as: Symbol) # {:age=>20, :last_name=>"Doe"}
755
654
  ```
756
655
 
757
- ### `Micro::Attributes.without`
656
+ ### `with:` and `without:`
758
657
 
759
- Picking *except* one or more features
658
+ `with:` includes the value of additional instance methods; `without:` excludes attribute keys:
760
659
 
761
660
  ```ruby
762
- Micro::Attributes.without(:diff) # will load :activemodel_validations, :keys_as_symbol and initialize: :strict
763
-
764
- Micro::Attributes.without(initialize: :strict) # will load :activemodel_validations, :diff and :keys_as_symbol
661
+ person.attributes(without: :age) # {"first_name"=>"John", "last_name"=>"Doe"}
662
+ person.attributes(with: [:name], without: [:first_name, :last_name]) # {"age"=>20, "name"=>"John Doe"}
663
+ person.attributes(:age, with: [:name]) # {:age=>20, "name"=>"John Doe"}
664
+ person.attributes(:age, with: [:name], keys_as: Symbol) # {:age=>20, :name=>"John Doe"}
765
665
  ```
766
666
 
767
- You can also pair `:accept` with any other feature, and switch into strict mode by passing the
768
- hash form `accept: :strict`:
769
-
770
- ```ruby
771
- Micro::Attributes.with(:accept)
772
-
773
- Micro::Attributes.with(:accept, :diff, :initialize)
667
+ ## `#defined_attributes`
774
668
 
775
- Micro::Attributes.with(:accept, :activemodel_validations, :diff, :keys_as_symbol)
776
-
777
- Micro::Attributes.with(:diff, :keys_as_symbol, initialize: :strict, accept: :strict)
778
- ```
779
-
780
- ## Picking all the features
669
+ Returns the list of attribute names declared on the instance's class. Unlike `Person.attributes` (which requires the class on hand) or `#attributes` (which returns values), `#defined_attributes` gives you just the names from any instance — handy in serializers, decorators, or generic code that traverses arbitrary objects.
781
670
 
782
671
  ```ruby
783
- Micro::Attributes.with_all_features
784
-
785
- # This method returns the same of:
786
-
787
- Micro::Attributes.with(:accept, :activemodel_validations, :diff, :keys_as_symbol, initialize: :strict)
672
+ person.defined_attributes # ["age", "first_name", "last_name"]
788
673
  ```
789
674
 
790
- [⬆️ Back to Top](#table-of-contents-)
675
+ [⬆️ Back to top](#-attributes)
791
676
 
792
- ## Extensions
677
+ # Other extensions
793
678
 
794
- ### Accept extension
679
+ ## Accept extension
795
680
 
796
- The `:accept` extension adds a lightweight, dependency-free validation mechanism. Use the
797
- `accept:` / `reject:` options on an attribute to validate the assigned value, and inspect the
798
- result through `#attributes_errors`, `#accepted_attributes`, and `#rejected_attributes`.
681
+ The `:accept` extension adds lightweight, dependency-free value validation. Use the `accept:` / `reject:` options on an attribute to validate the assigned value, then inspect the result through `#attributes_errors`, `#accepted_attributes`, and `#rejected_attributes`.
799
682
 
800
683
  ```ruby
801
684
  class User
@@ -807,7 +690,6 @@ class User
807
690
  end
808
691
 
809
692
  user = User.new({})
810
-
811
693
  user.attributes_errors? # false
812
694
  user.accepted_attributes? # true
813
695
  user.rejected_attributes? # false
@@ -820,26 +702,25 @@ User.new(age: 'twenty', email: nil).tap do |bad|
820
702
  end
821
703
  ```
822
704
 
823
- #### What can `accept:` / `reject:` receive?
705
+ ### What can `accept:` / `reject:` receive?
824
706
 
825
- | Type | `accept:` means | `reject:` means |
826
- | --------------- | -------------------------------------------- | ------------------------------------------------ |
827
- | `Class`/`Module`| `value.kind_of?(expected)` must be true | `value.kind_of?(expected)` must be false |
828
- | Predicate `:sym?` (ends with `?`) | `value.public_send(:sym?)` must be true | `value.public_send(:sym?)` must be false |
707
+ | Type | `accept:` means | `reject:` means |
708
+ | -------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------- |
709
+ | `Class`/`Module` | `value.kind_of?(expected)` must be true | `value.kind_of?(expected)` must be false |
710
+ | Predicate `:sym?` (ends with `?`) | `value.public_send(:sym?)` must be true | `value.public_send(:sym?)` must be false |
829
711
  | Anything callable (proc, lambda, object responding to `#call`) | result of `expected.call(value)` must be truthy | result of `expected.call(value)` must be falsy |
830
712
 
831
- Default rejection messages follow the pattern below; you can override them with
832
- `rejection_message:` (see further down).
713
+ Default rejection messages follow the pattern below; override them with `rejection_message:` (next subsection).
833
714
 
834
715
  ```ruby
835
- attribute :name, accept: :present? # "expected to be present?"
836
- attribute :name, reject: :empty? # "expected to not be empty?"
837
- attribute :name, accept: String # "expected to be a kind of String"
838
- attribute :name, reject: String # "expected to not be a kind of String"
716
+ attribute :name, accept: :present? # "expected to be present?"
717
+ attribute :name, reject: :empty? # "expected to not be empty?"
718
+ attribute :name, accept: String # "expected to be a kind of String"
719
+ attribute :name, reject: String # "expected to not be a kind of String"
839
720
  attribute :name, accept: ->(v) { v } # "is invalid"
840
721
  ```
841
722
 
842
- #### `allow_nil:` option
723
+ ### `allow_nil:` option
843
724
 
844
725
  Skip validation when the incoming value is `nil`.
845
726
 
@@ -855,11 +736,9 @@ User.new(age: 21).attributes_errors? # false
855
736
  User.new(age: 'x').attributes_errors? # true
856
737
  ```
857
738
 
858
- #### `rejection_message:` option
739
+ ### `rejection_message:` option
859
740
 
860
- Customize the error message either with a String or with a callable. A callable receives the
861
- attribute name as its first argument, so the same builder can be reused across attributes (handy
862
- for i18n).
741
+ Customize the error with a String or a callable. The callable receives the attribute name as its first argument, so the same builder works across attributes (handy for i18n).
863
742
 
864
743
  ```ruby
865
744
  class User
@@ -873,8 +752,7 @@ User.new(name: 1, age: 'x').attributes_errors
873
752
  # => { "name" => "must be a string", "age" => "age must be an integer" }
874
753
  ```
875
754
 
876
- Callable validators can also expose a `#rejection_message` method themselves, and it will be used
877
- as the default message for that validator:
755
+ Callable validators can also expose a `#rejection_message` method themselves; it becomes the default message for that validator:
878
756
 
879
757
  ```ruby
880
758
  class FilledString
@@ -894,10 +772,9 @@ class User
894
772
  end
895
773
  ```
896
774
 
897
- #### Strict mode (`accept: :strict`)
775
+ ### Strict mode (`accept: :strict`)
898
776
 
899
- Use `Micro::Attributes.with(accept: :strict)` to raise as soon as any attribute is rejected,
900
- instead of collecting errors silently.
777
+ Use `Micro::Attributes.with(accept: :strict)` to raise as soon as any attribute is rejected, instead of collecting errors silently.
901
778
 
902
779
  ```ruby
903
780
  class User
@@ -914,15 +791,11 @@ User.new(age: 'x', name: nil)
914
791
  # * :name is invalid
915
792
  ```
916
793
 
917
- #### Interaction with other features
794
+ ### Interaction with other features
918
795
 
919
- - Validation runs **after** the default value resolution, so defaults are validated like any
920
- regular value.
921
- - When combined with the [ActiveModel::Validation extension](#activemodelvalidation-extension),
922
- the `:accept` checks run first; AM validations only run if every attribute is accepted.
923
- - `accept:` plays nicely with [`freeze:`](#freezing-attribute-values-freeze) and
924
- [`private:`/`protected:`](#attribute-visibility-private-protected). See the combined example
925
- below.
796
+ - Validation runs **after** default-value resolution, so defaults are validated like any regular value.
797
+ - Combined with the [ActiveModel validations extension](#activemodel-validations-extension), the `:accept` checks run first; AM validations only run if every attribute is accepted.
798
+ - `accept:` plays nicely with [`freeze:`](#freezing-values-freeze) and [`private:` / `protected:`](#visibility-private-protected):
926
799
 
927
800
  ```ruby
928
801
  require 'digest'
@@ -932,10 +805,13 @@ class User::SignUpParams
932
805
 
933
806
  TrimString = ->(value) { String(value).strip }
934
807
 
935
- attribute :email, default: TrimString,
936
- accept: ->(s) { s =~ /\A.+@.+\..+\z/ }, freeze: :after_dup
937
- attributes :password, :password_confirmation, default: TrimString,
938
- reject: :empty?, private: true
808
+ attribute :email, accept: ->(s) { s =~ /\A.+@.+\..+\z/ },
809
+ default: TrimString,
810
+ freeze: :after_dup
811
+
812
+ attributes :password, :password_confirmation, reject: :empty?,
813
+ default: TrimString,
814
+ private: true
939
815
 
940
816
  def password_digest
941
817
  Digest::SHA256.hexdigest(password) if password == password_confirmation
@@ -943,15 +819,13 @@ class User::SignUpParams
943
819
  end
944
820
  ```
945
821
 
946
- [⬆️ Back to Top](#table-of-contents-)
822
+ ## ActiveModel validations extension
947
823
 
948
- ### `ActiveModel::Validation` extension
949
-
950
- If your application uses ActiveModel as a dependency (like a regular Rails app). You will be enabled to use the `activemodel_validations` extension.
824
+ If your application uses ActiveModel (e.g. a regular Rails app), enable the `activemodel_validations` extension.
951
825
 
952
826
  ```ruby
953
827
  class Job
954
- include Micro::Attributes.with(:activemodel_validations)
828
+ include Micro::Attributes.with(active_model: :validations)
955
829
 
956
830
  attribute :id
957
831
  attribute :state, default: 'sleeping'
@@ -962,21 +836,18 @@ end
962
836
  Job.new({}) # ActiveModel::StrictValidationFailed (Id can't be blank)
963
837
 
964
838
  job = Job.new(id: 1)
965
-
966
839
  job.id # 1
967
- job.state # 'sleeping'
840
+ job.state # "sleeping"
968
841
  ```
969
842
 
970
- #### `.attribute()` options
971
-
972
- You can use the `validate` or `validates` options to define your attributes. e.g.
843
+ You can also pass `validate:` / `validates:` directly on `attribute`:
973
844
 
974
845
  ```ruby
975
846
  class Job
976
847
  include Micro::Attributes.with(:activemodel_validations)
977
848
 
978
- attribute :id, validates: { presence: true }
979
- attribute :state, validate: :must_be_a_filled_string
849
+ attribute :id, validates: { presence: true }
850
+ attribute :state, validate: :must_be_a_filled_string
980
851
 
981
852
  def must_be_a_filled_string
982
853
  return if state.is_a?(String) && state.present?
@@ -986,11 +857,9 @@ class Job
986
857
  end
987
858
  ```
988
859
 
989
- [⬆️ Back to Top](#table-of-contents-)
990
-
991
- ### Diff extension
860
+ ## Diff extension
992
861
 
993
- Provides a way to track changes in your object attributes.
862
+ Tracks changes between two instances.
994
863
 
995
864
  ```ruby
996
865
  require 'securerandom'
@@ -1002,167 +871,307 @@ class Job
1002
871
  attribute :state, default: 'sleeping'
1003
872
  end
1004
873
 
1005
- job = Job.new(id: SecureRandom.uuid())
874
+ job = Job.new(id: SecureRandom.uuid)
875
+ job_running = job.with_attribute(:state, 'running')
1006
876
 
1007
- job.id # A random UUID generated from SecureRandom.uuid(). e.g: 'e68bcc74-b91c-45c2-a904-12f1298cc60e'
1008
- job.state # 'sleeping'
877
+ job.state # "sleeping"
878
+ job_running.state # "running"
1009
879
 
1010
- job_running = job.with_attribute(:state, 'running')
880
+ changes = job.diff_attributes(job_running)
881
+
882
+ changes.present? # true
883
+ changes.blank? # false
884
+ changes.empty? # false
1011
885
 
1012
- job_running.state # 'running'
886
+ changes.changed? # true
887
+ changes.changed?(:id) # false
888
+ changes.changed?(:state) # true
889
+ changes.changed?(:state, from: 'sleeping', to: 'running') # true
1013
890
 
1014
- job_changes = job.diff_attributes(job_running)
891
+ changes.differences # {'state'=> {'from' => 'sleeping', 'to' => 'running'}}
892
+ ```
893
+
894
+ ## Keys as Symbol extension
895
+
896
+ Disables indifferent access; all keys are symbols.
897
+
898
+ The default mode stores everything as strings for indifferent access. `:keys_as_symbol` skips that string allocation, lowering memory pressure and GC churn — at the cost of requiring you to use symbols consistently for set/access.
899
+
900
+ ```ruby
901
+ class Job
902
+ include Micro::Attributes.with(:initialize, :keys_as_symbol)
1015
903
 
1016
- #-----------------------------#
1017
- # #present?, #blank?, #empty? #
1018
- #-----------------------------#
904
+ attribute :id
905
+ attribute :state, default: 'sleeping'
906
+ end
1019
907
 
1020
- job_changes.present? # true
1021
- job_changes.blank? # false
1022
- job_changes.empty? # false
908
+ job = Job.new(id: 1)
1023
909
 
1024
- #-----------#
1025
- # #changed? #
1026
- #-----------#
1027
- job_changes.changed? # true
910
+ job.attributes # {:id => 1, :state => "sleeping"}
1028
911
 
1029
- job_changes.changed?(:id) # false
912
+ job.attribute?(:id) # true
913
+ job.attribute?('id') # false
1030
914
 
1031
- job_changes.changed?(:state) # true
1032
- job_changes.changed?(:state, from: 'sleeping', to: 'running') # true
915
+ job.attribute(:id) # 1
916
+ job.attribute('id') # nil
1033
917
 
1034
- #----------------#
1035
- # #differences() #
1036
- #----------------#
1037
- job_changes.differences # {'state'=> {'from' => 'sleeping', 'to' => 'running'}}
918
+ job.attribute!(:id) # 1
919
+ job.attribute!('id') # NameError (undefined attribute `id)
1038
920
  ```
1039
921
 
1040
- [⬆️ Back to Top](#table-of-contents-)
922
+ This extension also makes the [Diff extension](#diff-extension) symbol-only (arguments and outputs).
923
+
924
+ [⬆️ Back to top](#-attributes)
925
+
926
+ # Composition
927
+
928
+ Every `Micro::Attributes` class — whether you reach for `include Micro::Attributes.with(...)`, the [`Micro::Attributes.new`](#microattributesnew) factory, or just `include Micro::Attributes` — composes recursively:
1041
929
 
1042
- ### Initialize extension
930
+ - `attribute :foo, accept: SomeMicroAttributesClass` automatically coerces a hash to that class.
931
+ - `attribute :foo do ... end` defines an anonymous nested class inline; the inline class inherits the outer's feature mix.
932
+ - Nested-attribute errors bubble up as `'is invalid'` markers, while the leaf retains the full rejection message. The same applies to ActiveModel validations.
1043
933
 
1044
- 1. Creates a constructor to assign the attributes.
1045
- 2. Add methods to build new instances when some data was assigned.
934
+ There's no `Micro::Entity` wrapper — composition lives in `Micro::Attributes` itself. Every combination of features is covered by `test/micro/attributes/composition_matrix_test.rb`, `with_matrix_test.rb`, and `projection_matrix_test.rb`.
935
+
936
+ ## `Micro::Attributes.new`
937
+
938
+ A `Struct.new`-style factory that returns a fresh class wired with the requested features. The preset is `{ initialize: true, accept: true }` — override per-key by passing `false` (off), `true` (on), or a variant symbol (`:strict`):
1046
939
 
1047
940
  ```ruby
1048
- class Job
1049
- include Micro::Attributes.with(:initialize)
941
+ User = Micro::Attributes.new do
942
+ attribute :name, accept: String
943
+ attribute :age, accept: Numeric
944
+ end
945
+
946
+ user = User.new(name: 'Rodrigo', age: 34)
947
+ user.name # "Rodrigo"
948
+
949
+ bad = User.new(name: :rodrigo, age: '34')
950
+ bad.attributes_errors
951
+ # {
952
+ # "name" => "expected to be a kind of String",
953
+ # "age" => "expected to be a kind of Numeric"
954
+ # }
955
+ ```
956
+
957
+ ### Enabling extensions
958
+
959
+ The factory accepts every key the [hash-style `Micro::Attributes.with(...)`](#picking-a-combination) accepts. Drop in any combination:
960
+
961
+ ```ruby
962
+ # Add Diff on top of the preset:
963
+ Counter = Micro::Attributes.new(diff: true) do
964
+ attribute :n
965
+ end
966
+
967
+ a = Counter.new(n: 1)
968
+ b = Counter.new(n: 2)
969
+ a.diff_attributes(b).changed?(:n) # true
1050
970
 
1051
- attributes :id, :state
971
+ # Upgrade Initialize and/or Accept to :strict:
972
+ Strict = Micro::Attributes.new(initialize: :strict, accept: :strict) do
973
+ attribute :n, accept: Integer
1052
974
  end
1053
975
 
1054
- job_null = Job.new({})
976
+ Strict.new({}) # ArgumentError: missing keyword: :n
977
+ Strict.new(n: 'x') # ArgumentError: One or more attributes were rejected. ...
1055
978
 
1056
- job.id # nil
1057
- job.state # nil
979
+ # Symbol-keyed storage:
980
+ Sym = Micro::Attributes.new(keys_as: :symbol) do
981
+ attribute :n
982
+ end
983
+ Sym.new(n: 1).attributes # { n: 1 }
1058
984
 
1059
- job = Job.new(id: 1, state: 'sleeping')
985
+ # ActiveModel:
986
+ Person = Micro::Attributes.new(active_model: :validations) do
987
+ attribute :name, validates: { presence: true }
988
+ end
989
+ Person.new(name: nil).valid? # false
990
+ Person.new(name: nil).errors.full_messages # ["Name can't be blank"]
991
+
992
+ # All together:
993
+ StrictPerson = Micro::Attributes.new(
994
+ initialize: :strict,
995
+ accept: true,
996
+ diff: true,
997
+ keys_as: :symbol,
998
+ active_model: :validations
999
+ ) do
1000
+ attribute :name, accept: String, validates: { presence: true }
1001
+ attribute :age, accept: Numeric
1002
+ end
1060
1003
 
1061
- job.id # 1
1062
- job.state # 'sleeping'
1004
+ StrictPerson.new(name: 'X') # ArgumentError: missing keyword: :age
1005
+ ```
1006
+
1007
+ Each key is overridable per-call: the preset is `{ initialize: true, accept: true }`, so passing `accept: false` (or `nil`) opts out of `:accept` and the returned class has no `attributes_errors` surface. `initialize:` must resolve to `true` or `:strict` — passing `false` raises, because a factory-built class without a hash constructor is almost always a mistake.
1008
+
1009
+ ## Nested attributes via `accept:`
1063
1010
 
1064
- ##############################################
1065
- # Assigning new values to get a new instance #
1066
- ##############################################
1011
+ When `accept:` is another class that includes `Micro::Attributes` **and has `:initialize`**, hashes assigned to that attribute are auto-coerced into an instance of the target class. Already-built instances pass through unchanged. If the target lacks `:initialize` (you provide your own constructor), the hash passes through and the standard accept check applies — no auto-coercion.
1067
1012
 
1068
- #-------------------#
1069
- # #with_attribute() #
1070
- #-------------------#
1013
+ ```ruby
1014
+ Address = Micro::Attributes.new do
1015
+ attribute :city, accept: String
1016
+ attribute :postal, accept: String
1017
+ end
1071
1018
 
1072
- new_job = job.with_attribute(:state, 'running')
1019
+ Profile = Micro::Attributes.new do
1020
+ attribute :name, accept: String
1021
+ attribute :address, accept: Address
1022
+ end
1073
1023
 
1074
- new_job.id # 1
1075
- new_job.state # running
1076
- new_job.equal?(job) # false
1024
+ profile = Profile.new(name: 'Rodrigo', address: { city: 'Rio', postal: '20000-000' })
1077
1025
 
1078
- #--------------------#
1079
- # #with_attributes() #
1080
- #--------------------#
1081
- #
1082
- # Use it to assign multiple attributes
1026
+ profile.address.class # Address
1027
+ profile.address.city # "Rio"
1083
1028
 
1084
- other_job = job.with_attributes(id: 2, state: 'killed')
1029
+ # Already-built instances pass through:
1030
+ addr = Address.new(city: 'Rio', postal: '20000-000')
1031
+ profile = Profile.new(name: 'Rodrigo', address: addr)
1085
1032
 
1086
- other_job.id # 2
1087
- other_job.state # killed
1088
- other_job.equal?(job) # false
1033
+ profile.address.equal?(addr) # true
1089
1034
  ```
1090
1035
 
1091
- [⬆️ Back to Top](#table-of-contents-)
1036
+ > **Note:** error surfacing through `attributes_errors?` / `attributes_errors` (and the deep-bubble marker) requires the **parent** to also include `:accept`. A parent without `:accept` will still coerce hashes into child instances, but it has no `attributes_errors` machinery to mirror descendant invalidity — `parent.child.attributes_errors?` may be true while the parent looks clean. Walk the tree explicitly in that case, or include `:accept` on the parent.
1092
1037
 
1093
- #### Strict mode
1038
+ ## Defining nested attributes inline (block form)
1094
1039
 
1095
- 1. Creates a constructor to assign the attributes.
1096
- 2. Adds methods to build new instances when some data was assigned.
1097
- 3. **Forbids missing keywords**.
1040
+ `attribute` accepts a block. The block defines an anonymous nested class with the **same feature mix** as the host — strict / symbol-keys / AM all propagate:
1098
1041
 
1099
1042
  ```ruby
1100
- class Job
1101
- include Micro::Attributes.with(initialize: :strict)
1043
+ Order = Micro::Attributes.new do
1044
+ attribute :id, accept: Integer
1102
1045
 
1103
- attributes :id, :state
1046
+ attribute :customer do
1047
+ attribute :name, accept: String
1048
+ attribute :email, accept: String
1049
+ end
1104
1050
  end
1105
- #-----------------------------------------------------------------------#
1106
- # The strict initialize mode will require all the keys when initialize. #
1107
- #-----------------------------------------------------------------------#
1108
1051
 
1109
- Job.new({})
1052
+ order = Order.new(id: 1, customer: { name: 'Rodrigo', email: 'rodrigo@example.com' })
1053
+ order.customer.name # "Rodrigo"
1054
+ ```
1055
+
1056
+ The inline class uses the host's `Micro::Attributes.with(...)` module, so a `keys_as: :symbol` host yields a symbol-keyed inline child, an `initialize: :strict` host yields a strict inline child, and so on.
1057
+
1058
+ ### Per-block extensions
1110
1059
 
1111
- # The code above will raise:
1112
- # ArgumentError (missing keywords: :id, :state)
1060
+ The block also accepts the `with(...)` class macro — the same one used to layer features at the top of a class body. Calling it as the first thing inside the block layers **additional** features onto just that inline child, on top of whatever the host already provides:
1113
1061
 
1114
- #---------------------------#
1115
- # Samples passing some data #
1116
- #---------------------------#
1062
+ ```ruby
1063
+ class Order
1064
+ include Micro::Attributes.with(:initialize, :accept)
1117
1065
 
1118
- job_null = Job.new(id: nil, state: nil)
1066
+ attribute :customer do
1067
+ with diff: true, active_model: :validations
1119
1068
 
1120
- job.id # nil
1121
- job.state # nil
1069
+ attribute :name, accept: String, validates: { presence: true }
1070
+ end
1122
1071
 
1123
- job = Job.new(id: 1, state: 'sleeping')
1072
+ attribute :address do # this one stays minimal
1073
+ attribute :city, accept: String
1074
+ end
1075
+ end
1124
1076
 
1125
- job.id # 1
1126
- job.state # 'sleeping'
1077
+ order = Order.new(
1078
+ customer: { name: 'Rodrigo' },
1079
+ address: { city: 'Lisbon' }
1080
+ )
1081
+
1082
+ order.customer.respond_to?(:diff_attributes) # true — Diff layered just here
1083
+ order.customer.valid? # true
1084
+ order.address.respond_to?(:diff_attributes) # false — sibling block did not bleed across
1127
1085
  ```
1128
1086
 
1129
- > **Note**: This extension works like the `initialize` extension. So, look at its section to understand all of the other features.
1087
+ Sibling blocks are independent `with(...)` only affects the block it appears in. Positional symbols work too (`with :diff, :keys_as_symbol`), mirroring the [hash-style options](#picking-a-combination).
1130
1088
 
1131
- [⬆️ Back to Top](#table-of-contents-)
1089
+ You can only **add** features inside a block, not remove ones the host already enabled. If a specific nested entity needs to opt OUT of an extension the host has, define it as a separate class via [`Micro::Attributes.new(...)`](#microattributesnew) (or `include Micro::Attributes.with(...)`) and reference it through `accept: TheOtherClass` instead of using the block form.
1132
1090
 
1133
- ### Keys as symbol extension
1091
+ ## Deep nesting & validation bubbling
1134
1092
 
1135
- Disables the indifferent access requiring the declaration/usage of the attributes as symbols.
1093
+ Both forms (class-based via `accept:` and block-form) compose recursively to any depth. Each level carries its own `attributes_errors` / `errors`; any descendant invalidity is **mirrored** up the chain as a `'is invalid'` marker while the leaf retains the original message.
1136
1094
 
1137
- The advantage of this extension over the default behavior is because it avoids an unnecessary allocation in memory of strings. All the keys are transformed into strings in the indifferent access mode, but, with this extension, this typecasting will be avoided. So, it has a better performance and reduces the usage of memory/Garbage collector, but gives for you the responsibility to always use symbols to set/access the attributes.
1095
+ ### Accept-error bubbling (no ActiveModel needed)
1138
1096
 
1139
1097
  ```ruby
1140
- class Job
1141
- include Micro::Attributes.with(:initialize, :keys_as_symbol)
1098
+ City = Micro::Attributes.new { attribute :name, accept: String }
1099
+ Address = Micro::Attributes.new { attribute :city, accept: City }
1100
+ Profile = Micro::Attributes.new { attribute :address, accept: Address }
1142
1101
 
1143
- attribute :id
1144
- attribute :state, default: 'sleeping'
1102
+ profile = Profile.new(address: { city: { name: 42 } })
1103
+
1104
+ # Leaf has the detail
1105
+ profile.address.city.attributes_errors # {"name" => "expected to be a kind of String"}
1106
+
1107
+ # Every ancestor mirrors the invalidity
1108
+ profile.attributes_errors? # true
1109
+ profile.attributes_errors # {"address" => "is invalid"}
1110
+ profile.address.attributes_errors # {"city" => "is invalid"}
1111
+ ```
1112
+
1113
+ ### ActiveModel deep validation
1114
+
1115
+ When `active_model: :validations` (or `:activemodel_validations` positional) is in the feature mix, `parent.valid?` reflects deep descendant invalidity automatically:
1116
+
1117
+ ```ruby
1118
+ Leaf = Micro::Attributes.new(active_model: :validations) do
1119
+ attribute :name, accept: String, validates: { presence: true }
1145
1120
  end
1146
1121
 
1147
- job = Job.new(id: 1)
1122
+ Mid = Micro::Attributes.new(active_model: :validations) do
1123
+ attribute :leaf, accept: Leaf
1124
+ end
1148
1125
 
1149
- job.attributes # {:id => 1, :state => "sleeping"}
1126
+ Root = Micro::Attributes.new(active_model: :validations) do
1127
+ attribute :mid, accept: Mid
1128
+ end
1150
1129
 
1151
- job.attribute?(:id) # true
1152
- job.attribute?('id') # false
1130
+ root = Root.new(mid: { leaf: { name: '' } })
1153
1131
 
1154
- job.attribute(:id) # 1
1155
- job.attribute('id') # nil
1132
+ root.valid? # false — bubbled up
1133
+ root.errors[:mid] # ["is invalid"]
1134
+ root.mid.errors[:leaf] # ["is invalid"]
1135
+ root.mid.leaf.errors[:name] # ["can't be blank"] ← detail at the leaf
1136
+ ```
1156
1137
 
1157
- job.attribute!(:id) # 1
1158
- job.attribute!('id') # NameError (undefined attribute `id)
1138
+ Mixed trees work too — if a child has no AM, the validator falls back to checking its `attributes_errors?`:
1139
+
1140
+ ```ruby
1141
+ AcceptLeaf = Micro::Attributes.new do # no AM
1142
+ attribute :name, accept: String
1143
+ end
1144
+
1145
+ AMRoot = Micro::Attributes.new(active_model: :validations) do
1146
+ attribute :leaf, accept: AcceptLeaf
1147
+ end
1148
+
1149
+ AMRoot.new(leaf: { name: 42 }).valid? # false — accept-error on the leaf bubbles to AM on root
1159
1150
  ```
1160
1151
 
1161
- As you could see in the previous example only symbols will work to do something with the attributes.
1152
+ The contract: **detail at the leaf, marker at every ancestor.** Walk the tree (`obj.mid.leaf.attributes_errors`) for the message; use `obj.attributes_errors?` / `obj.valid?` at the top to gate flow.
1153
+
1154
+ [⬆️ Back to top](#-attributes)
1155
+
1156
+ # Notes
1157
+
1158
+ ## What "immutable" means here (and thread safety)
1159
+
1160
+ `u-attributes` provides **structural** immutability: there are no setters, and `#with_attribute(s)` returns a new instance instead of mutating the old one. The stored values themselves are **not** deep-frozen by default — if you assign a mutable object (an `Array`, `Hash`, custom class instance), callers that share that reference can still mutate it in place.
1161
+
1162
+ For full immutability of contained values, combine `freeze:` (which freezes the stored value after assignment) with already-frozen inputs, or freeze nested structures yourself before assignment. The `:after_dup` / `:after_clone` modes give you a safe boundary — the copy is frozen, the original isn't.
1163
+
1164
+ This means instances are safe to share across threads only to the extent that the values they hold are also safe to share.
1165
+
1166
+ ## Unknown keys in `extract_attributes_from`
1167
+
1168
+ `extract_attributes_from(source)` reads only names that the class declared via `attribute` / `attributes`. Any extra keys on a hash source — or extra methods on an object source — are silently ignored. No warning, no error.
1169
+
1170
+ ## Migrating from 2.x
1162
1171
 
1163
- This extension also changes the `diff extension` making everything (arguments, outputs) working only with symbols.
1172
+ The 2.x line ([README](https://github.com/serradura/u-attributes/blob/v2.x/README.md)) supported Ruby `>= 2.2` and a broader `activemodel` range. 3.x raises the floors (Ruby `>= 2.7`, `activemodel >= 6.0`) and reshapes the extension API toward the `Micro::Attributes.with(...)` form documented here. See [`CHANGELOG.md`](./CHANGELOG.md) for the full list of changes.
1164
1173
 
1165
- [⬆️ Back to Top](#table-of-contents-)
1174
+ [⬆️ Back to top](#-attributes)
1166
1175
 
1167
1176
  # Development
1168
1177
 
@@ -1180,4 +1189,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
1180
1189
 
1181
1190
  # Code of Conduct
1182
1191
 
1183
- Everyone interacting in the Micro::Attributes projects codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/serradura/u-attributes/blob/main/CODE_OF_CONDUCT.md).
1192
+ Everyone interacting in the Micro::Attributes project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/serradura/u-attributes/blob/main/CODE_OF_CONDUCT.md).