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/README.md
CHANGED
|
@@ -13,60 +13,127 @@
|
|
|
13
13
|
</p>
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
- [
|
|
31
|
-
- [
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
- [
|
|
40
|
-
- [
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
- [
|
|
44
|
-
- [
|
|
45
|
-
- [
|
|
46
|
-
|
|
47
|
-
- [
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
- [
|
|
58
|
-
|
|
59
|
-
- [`
|
|
60
|
-
- [`
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
86
|
-
|
|
|
87
|
-
| unreleased
|
|
88
|
-
| 3.0
|
|
89
|
-
| 2.8.0
|
|
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
|
|
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
|
-
|
|
173
|
+
# Feature overview
|
|
111
174
|
|
|
112
|
-
|
|
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
|
-
|
|
119
|
-
attribute :name
|
|
177
|
+
## What you get by default
|
|
120
178
|
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
person.name # John Doe
|
|
196
|
+
## Opt-in extensions
|
|
130
197
|
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
+
## Picking a combination
|
|
141
211
|
|
|
142
|
-
|
|
212
|
+
Two equivalent ways to enable features:
|
|
143
213
|
|
|
144
214
|
```ruby
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
235
|
+
Calling `with` with no arguments raises:
|
|
182
236
|
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
+
```ruby
|
|
246
|
+
Micro::Attributes.with_all_features
|
|
191
247
|
|
|
192
|
-
|
|
193
|
-
|
|
248
|
+
# Same as:
|
|
249
|
+
Micro::Attributes.with(:accept, :activemodel_validations, :diff, :keys_as_symbol, initialize: :strict)
|
|
250
|
+
```
|
|
194
251
|
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
another_person.name # Julia Not Roberts
|
|
201
|
-
```
|
|
256
|
+
[⬆️ Back to top](#-attributes)
|
|
202
257
|
|
|
203
|
-
|
|
258
|
+
# Defining attributes
|
|
204
259
|
|
|
205
|
-
|
|
260
|
+
## `attribute` and `attributes`
|
|
206
261
|
|
|
207
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
end
|
|
268
|
+
attribute :name
|
|
269
|
+
attributes :age, :score, default: 0
|
|
219
270
|
end
|
|
220
271
|
|
|
221
|
-
Person.new(
|
|
272
|
+
Person.new(name: 'Ada').name # "Ada"
|
|
273
|
+
Person.new(name: 'Ada').age # 0
|
|
222
274
|
```
|
|
223
275
|
|
|
224
|
-
|
|
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
|
-
|
|
278
|
+
## Defaults
|
|
227
279
|
|
|
228
|
-
|
|
280
|
+
Pass `default:` with either a static value or a callable.
|
|
229
281
|
|
|
230
282
|
```ruby
|
|
231
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
291
|
+
If the callable takes an argument, it receives the incoming value and can transform it before assignment.
|
|
247
292
|
|
|
248
|
-
|
|
293
|
+
## Required attributes
|
|
249
294
|
|
|
250
|
-
|
|
295
|
+
`required: true` raises when the key is missing from the constructor:
|
|
251
296
|
|
|
252
297
|
```ruby
|
|
253
|
-
|
|
298
|
+
class Person
|
|
299
|
+
include Micro::Attributes.with(:initialize)
|
|
300
|
+
|
|
301
|
+
attribute :age
|
|
302
|
+
attribute :name, required: true
|
|
303
|
+
end
|
|
254
304
|
|
|
255
|
-
|
|
305
|
+
Person.new(age: 32) # ArgumentError (missing keyword: :name)
|
|
256
306
|
```
|
|
257
307
|
|
|
258
|
-
|
|
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
|
-
|
|
310
|
+
## Visibility (`private:`, `protected:`)
|
|
261
311
|
|
|
262
|
-
By default every attribute reader is `public`. Use
|
|
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
|
|
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
|
|
344
|
+
user.attributes # { "email" => "email@example.com" }
|
|
299
345
|
|
|
300
|
-
user.attribute?('email')
|
|
301
|
-
user.attribute?('password')
|
|
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')
|
|
305
|
-
user.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
|
|
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
|
-
|
|
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
|
-
|
|
362
|
+
## Freezing values (`freeze:`)
|
|
321
363
|
|
|
322
|
-
Use
|
|
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
|
|
381
|
+
raw_name = +"Rodrigo"
|
|
382
|
+
raw_address = +"Av. Paulista"
|
|
341
383
|
|
|
342
384
|
person = Person.new(
|
|
343
385
|
name: raw_name,
|
|
344
|
-
address:
|
|
386
|
+
address: raw_address,
|
|
345
387
|
payload: { id: 1 }
|
|
346
388
|
)
|
|
347
389
|
|
|
348
|
-
person.name.frozen?
|
|
349
|
-
raw_name.frozen?
|
|
390
|
+
person.name.frozen? # true
|
|
391
|
+
raw_name.frozen? # true ← freeze: true mutates the original
|
|
350
392
|
|
|
351
|
-
person.address.frozen?
|
|
352
|
-
|
|
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
|
-
|
|
399
|
+
## Inheritance and `.attribute!`
|
|
359
400
|
|
|
360
|
-
|
|
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
|
-
|
|
407
|
+
attribute :age
|
|
408
|
+
attribute :name, default: 'John Doe'
|
|
409
|
+
end
|
|
369
410
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
end
|
|
411
|
+
class Subclass < Person
|
|
412
|
+
attribute :foo
|
|
373
413
|
end
|
|
374
414
|
|
|
375
|
-
|
|
415
|
+
instance = Subclass.new({})
|
|
376
416
|
|
|
377
|
-
|
|
378
|
-
|
|
417
|
+
instance.name # "John Doe"
|
|
418
|
+
instance.respond_to?(:age) # true
|
|
419
|
+
instance.respond_to?(:foo) # true
|
|
379
420
|
```
|
|
380
421
|
|
|
381
|
-
|
|
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
|
|
387
|
-
|
|
425
|
+
class AnotherSubclass < Person
|
|
426
|
+
attribute! :name, default: 'Alfa'
|
|
427
|
+
end
|
|
388
428
|
|
|
389
|
-
|
|
429
|
+
AnotherSubclass.new({}).name # "Alfa"
|
|
390
430
|
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
+
# The initialize extension
|
|
401
443
|
|
|
402
|
-
|
|
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
|
-
|
|
446
|
+
## Standard mode
|
|
405
447
|
|
|
406
448
|
```ruby
|
|
407
449
|
class Person
|
|
408
450
|
include Micro::Attributes.with(:initialize)
|
|
409
451
|
|
|
410
|
-
attribute :age,
|
|
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
|
-
|
|
421
|
-
|
|
480
|
+
Aside from that validation, strict mode behaves identically to the standard mode.
|
|
481
|
+
|
|
482
|
+
## `#with_attribute()`
|
|
422
483
|
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
+
Passing a non-Hash raises:
|
|
445
507
|
|
|
446
508
|
```ruby
|
|
447
|
-
|
|
509
|
+
person.with_attributes(1) # Kind::Error (1 expected to be a kind of Hash)
|
|
448
510
|
```
|
|
449
511
|
|
|
450
|
-
|
|
512
|
+
## Extracting attributes from another object or hash
|
|
451
513
|
|
|
452
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
546
|
+
## Writing your own constructor
|
|
511
547
|
|
|
512
|
-
|
|
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
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
+
[⬆️ Back to top](#-attributes)
|
|
563
569
|
|
|
564
|
-
|
|
570
|
+
# Reading attributes
|
|
565
571
|
|
|
566
|
-
|
|
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,
|
|
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
|
-
|
|
587
|
+
person = Person.new(age: 20)
|
|
588
|
+
```
|
|
587
589
|
|
|
588
|
-
|
|
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
|
-
|
|
600
|
+
## Instance-level: `#attribute`, `#attribute!`, `#attribute?`
|
|
607
601
|
|
|
608
|
-
|
|
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
|
|
612
|
-
|
|
613
|
-
person.attribute
|
|
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
|
|
617
|
-
person.attribute
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
+
## The `#attributes` hash
|
|
666
629
|
|
|
667
|
-
|
|
630
|
+
The bare call returns every public attribute with its current value:
|
|
668
631
|
|
|
669
632
|
```ruby
|
|
670
|
-
person
|
|
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
|
-
|
|
636
|
+
### `keys_as:` — control key type
|
|
684
637
|
|
|
685
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
### `
|
|
656
|
+
### `with:` and `without:`
|
|
758
657
|
|
|
759
|
-
|
|
658
|
+
`with:` includes the value of additional instance methods; `without:` excludes attribute keys:
|
|
760
659
|
|
|
761
660
|
```ruby
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
675
|
+
[⬆️ Back to top](#-attributes)
|
|
791
676
|
|
|
792
|
-
|
|
677
|
+
# Other extensions
|
|
793
678
|
|
|
794
|
-
|
|
679
|
+
## Accept extension
|
|
795
680
|
|
|
796
|
-
The `:accept` extension adds
|
|
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
|
-
|
|
705
|
+
### What can `accept:` / `reject:` receive?
|
|
824
706
|
|
|
825
|
-
| Type
|
|
826
|
-
|
|
|
827
|
-
| `Class`/`Module
|
|
828
|
-
| Predicate `:sym?` (ends with `?`)
|
|
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;
|
|
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?
|
|
836
|
-
attribute :name, reject: :empty?
|
|
837
|
-
attribute :name, accept: String
|
|
838
|
-
attribute :name, reject: 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
|
-
|
|
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
|
-
|
|
739
|
+
### `rejection_message:` option
|
|
859
740
|
|
|
860
|
-
Customize the error
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
794
|
+
### Interaction with other features
|
|
918
795
|
|
|
919
|
-
- Validation runs **after**
|
|
920
|
-
|
|
921
|
-
-
|
|
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,
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
|
|
822
|
+
## ActiveModel validations extension
|
|
947
823
|
|
|
948
|
-
|
|
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(:
|
|
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 #
|
|
840
|
+
job.state # "sleeping"
|
|
968
841
|
```
|
|
969
842
|
|
|
970
|
-
|
|
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,
|
|
979
|
-
attribute :state, validate:
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
### Diff extension
|
|
860
|
+
## Diff extension
|
|
992
861
|
|
|
993
|
-
|
|
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
|
|
874
|
+
job = Job.new(id: SecureRandom.uuid)
|
|
875
|
+
job_running = job.with_attribute(:state, 'running')
|
|
1006
876
|
|
|
1007
|
-
job.
|
|
1008
|
-
|
|
877
|
+
job.state # "sleeping"
|
|
878
|
+
job_running.state # "running"
|
|
1009
879
|
|
|
1010
|
-
|
|
880
|
+
changes = job.diff_attributes(job_running)
|
|
881
|
+
|
|
882
|
+
changes.present? # true
|
|
883
|
+
changes.blank? # false
|
|
884
|
+
changes.empty? # false
|
|
1011
885
|
|
|
1012
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
904
|
+
attribute :id
|
|
905
|
+
attribute :state, default: 'sleeping'
|
|
906
|
+
end
|
|
1019
907
|
|
|
1020
|
-
|
|
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
|
-
|
|
912
|
+
job.attribute?(:id) # true
|
|
913
|
+
job.attribute?('id') # false
|
|
1030
914
|
|
|
1031
|
-
|
|
1032
|
-
|
|
915
|
+
job.attribute(:id) # 1
|
|
916
|
+
job.attribute('id') # nil
|
|
1033
917
|
|
|
1034
|
-
|
|
1035
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
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
|
-
|
|
976
|
+
Strict.new({}) # ArgumentError: missing keyword: :n
|
|
977
|
+
Strict.new(n: 'x') # ArgumentError: One or more attributes were rejected. ...
|
|
1055
978
|
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
1070
|
-
|
|
1013
|
+
```ruby
|
|
1014
|
+
Address = Micro::Attributes.new do
|
|
1015
|
+
attribute :city, accept: String
|
|
1016
|
+
attribute :postal, accept: String
|
|
1017
|
+
end
|
|
1071
1018
|
|
|
1072
|
-
|
|
1019
|
+
Profile = Micro::Attributes.new do
|
|
1020
|
+
attribute :name, accept: String
|
|
1021
|
+
attribute :address, accept: Address
|
|
1022
|
+
end
|
|
1073
1023
|
|
|
1074
|
-
|
|
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
|
-
#
|
|
1080
|
-
#--------------------#
|
|
1081
|
-
#
|
|
1082
|
-
# Use it to assign multiple attributes
|
|
1026
|
+
profile.address.class # Address
|
|
1027
|
+
profile.address.city # "Rio"
|
|
1083
1028
|
|
|
1084
|
-
|
|
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
|
-
|
|
1087
|
-
other_job.state # killed
|
|
1088
|
-
other_job.equal?(job) # false
|
|
1033
|
+
profile.address.equal?(addr) # true
|
|
1089
1034
|
```
|
|
1090
1035
|
|
|
1091
|
-
|
|
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
|
-
|
|
1038
|
+
## Defining nested attributes inline (block form)
|
|
1094
1039
|
|
|
1095
|
-
|
|
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
|
-
|
|
1101
|
-
|
|
1043
|
+
Order = Micro::Attributes.new do
|
|
1044
|
+
attribute :id, accept: Integer
|
|
1102
1045
|
|
|
1103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
1062
|
+
```ruby
|
|
1063
|
+
class Order
|
|
1064
|
+
include Micro::Attributes.with(:initialize, :accept)
|
|
1117
1065
|
|
|
1118
|
-
|
|
1066
|
+
attribute :customer do
|
|
1067
|
+
with diff: true, active_model: :validations
|
|
1119
1068
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1069
|
+
attribute :name, accept: String, validates: { presence: true }
|
|
1070
|
+
end
|
|
1122
1071
|
|
|
1123
|
-
|
|
1072
|
+
attribute :address do # ← this one stays minimal
|
|
1073
|
+
attribute :city, accept: String
|
|
1074
|
+
end
|
|
1075
|
+
end
|
|
1124
1076
|
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1091
|
+
## Deep nesting & validation bubbling
|
|
1134
1092
|
|
|
1135
|
-
|
|
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
|
-
|
|
1095
|
+
### Accept-error bubbling (no ActiveModel needed)
|
|
1138
1096
|
|
|
1139
1097
|
```ruby
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
1122
|
+
Mid = Micro::Attributes.new(active_model: :validations) do
|
|
1123
|
+
attribute :leaf, accept: Leaf
|
|
1124
|
+
end
|
|
1148
1125
|
|
|
1149
|
-
|
|
1126
|
+
Root = Micro::Attributes.new(active_model: :validations) do
|
|
1127
|
+
attribute :mid, accept: Mid
|
|
1128
|
+
end
|
|
1150
1129
|
|
|
1151
|
-
|
|
1152
|
-
job.attribute?('id') # false
|
|
1130
|
+
root = Root.new(mid: { leaf: { name: '' } })
|
|
1153
1131
|
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
|
|
1158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 project
|
|
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).
|