domainic-attributer 0.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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +21 -0
- data/README.md +396 -0
- data/lib/domainic/attributer/attribute/callback.rb +68 -0
- data/lib/domainic/attributer/attribute/coercer.rb +93 -0
- data/lib/domainic/attributer/attribute/mixin/belongs_to_attribute.rb +68 -0
- data/lib/domainic/attributer/attribute/signature.rb +338 -0
- data/lib/domainic/attributer/attribute/validator.rb +128 -0
- data/lib/domainic/attributer/attribute.rb +256 -0
- data/lib/domainic/attributer/attribute_set.rb +208 -0
- data/lib/domainic/attributer/class_methods.rb +247 -0
- data/lib/domainic/attributer/dsl/attribute_builder/option_parser.rb +247 -0
- data/lib/domainic/attributer/dsl/attribute_builder.rb +233 -0
- data/lib/domainic/attributer/dsl/initializer.rb +130 -0
- data/lib/domainic/attributer/dsl/method_injector.rb +97 -0
- data/lib/domainic/attributer/dsl.rb +5 -0
- data/lib/domainic/attributer/instance_methods.rb +65 -0
- data/lib/domainic/attributer/undefined.rb +44 -0
- data/lib/domainic/attributer.rb +114 -0
- data/lib/domainic-attributer.rb +3 -0
- data/sig/domainic/attributer/attribute/callback.rbs +48 -0
- data/sig/domainic/attributer/attribute/coercer.rbs +59 -0
- data/sig/domainic/attributer/attribute/mixin/belongs_to_attribute.rbs +46 -0
- data/sig/domainic/attributer/attribute/signature.rbs +223 -0
- data/sig/domainic/attributer/attribute/validator.rbs +83 -0
- data/sig/domainic/attributer/attribute.rbs +150 -0
- data/sig/domainic/attributer/attribute_set.rbs +134 -0
- data/sig/domainic/attributer/class_methods.rbs +151 -0
- data/sig/domainic/attributer/dsl/attribute_builder/option_parser.rbs +130 -0
- data/sig/domainic/attributer/dsl/attribute_builder.rbs +156 -0
- data/sig/domainic/attributer/dsl/initializer.rbs +91 -0
- data/sig/domainic/attributer/dsl/method_injector.rbs +66 -0
- data/sig/domainic/attributer/dsl.rbs +1 -0
- data/sig/domainic/attributer/instance_methods.rbs +53 -0
- data/sig/domainic/attributer/undefined.rbs +14 -0
- data/sig/domainic/attributer.rbs +69 -0
- data/sig/domainic-attributer.rbs +1 -0
- data/sig/manifest.yaml +2 -0
- metadata +89 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a25981d1a30af4ee16bd8339c975c935eee76c0bcda186af868e28c4ae4874f8
|
4
|
+
data.tar.gz: c9dd81c87a0cc108010e4463beba162f30830182c485624ba0782de971ebb93f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 97e1331498dc427a98bcaf3dfa38828d1aafe02466bcbd2ae84101542b476a92543f611eed55f842537da5224fa568c8f812f475f21fec6c4831e1704c2b456e
|
7
|
+
data.tar.gz: f2a5d8b6540cc7aba2eed108a5181ea3db350dc8f3a52f30a386e94f78b0ebd9df984697ad7f92071f1bd338fa3c25619be0ff4a5e3054d52703570ba2884ea9
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# Changelog
|
2
|
+
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
|
+
|
5
|
+
The format is based on [Keep a Changelog], and this project adheres to [Break Versioning].
|
6
|
+
|
7
|
+
## [Unreleased]
|
8
|
+
|
9
|
+
[Keep a Changelog]: https://keepachangelog.com/en/1.0.0/
|
10
|
+
[Break Versioning]: https://www.taoensso.com/break-versioning
|
11
|
+
|
12
|
+
<!-- versions -->
|
13
|
+
|
14
|
+
[Unreleased]: https://github.com/domainic/domainic/tree/main/domainic-attributer
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) Aaron Allen
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,396 @@
|
|
1
|
+
# Domainic::Attributer
|
2
|
+
|
3
|
+
[](https://rubygems.org/gems/domainic-attributer)
|
4
|
+
|
5
|
+
Domainic::Attributer is a powerful toolkit for Ruby that brings clarity and safety to your class attributes. It's
|
6
|
+
designed to solve common Domain-Driven Design (DDD) challenges by making your class attributes self-documenting,
|
7
|
+
type-safe, and well-behaved. Ever wished your class attributes could:
|
8
|
+
|
9
|
+
* Validate themselves to ensure they only accept correct values?
|
10
|
+
* Transform input data automatically into the right format?
|
11
|
+
* Have clear, enforced visibility rules?
|
12
|
+
* Handle their own default values intelligently?
|
13
|
+
* Tell you when they change?
|
14
|
+
* Distinguish between required arguments and optional settings?
|
15
|
+
|
16
|
+
That's exactly what Domainic::Attributer does! It's particularly useful when building domain models, value objects, or
|
17
|
+
any Ruby classes where data integrity and clear interfaces matter. Instead of writing repetitive validation code, manual
|
18
|
+
type checking, and custom attribute methods, let Domainic::Attributer handle the heavy lifting while you focus on your
|
19
|
+
domain logic.
|
20
|
+
|
21
|
+
Think of it as giving your attributes a brain - they know what they want, how they should behave, and they're not afraid
|
22
|
+
to speak up when something's not right!
|
23
|
+
|
24
|
+
## Installation
|
25
|
+
|
26
|
+
Add this line to your application's Gemfile:
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
gem 'domainic-attributer'
|
30
|
+
```
|
31
|
+
|
32
|
+
Or install it yourself as:
|
33
|
+
|
34
|
+
```bash
|
35
|
+
gem install domainic-attributer
|
36
|
+
```
|
37
|
+
|
38
|
+
## Usage
|
39
|
+
|
40
|
+
### Basic Attributes
|
41
|
+
|
42
|
+
Getting started with Domainic::Attributer is as easy as including the module and declaring your attributes:
|
43
|
+
|
44
|
+
```ruby
|
45
|
+
class Person
|
46
|
+
include Domainic::Attributer
|
47
|
+
|
48
|
+
argument :name
|
49
|
+
option :age, default: nil
|
50
|
+
end
|
51
|
+
|
52
|
+
person = Person.new("Alice", age: 30)
|
53
|
+
person.name # => "Alice"
|
54
|
+
person.age # => 30
|
55
|
+
```
|
56
|
+
|
57
|
+
### Arguments vs Options
|
58
|
+
|
59
|
+
Domainic::Attributer gives you two ways to define attributes:
|
60
|
+
|
61
|
+
* `argument`: Required positional parameters that must be provided in order
|
62
|
+
* `option`: Named parameters that can be provided in any order (and are optional by default)
|
63
|
+
|
64
|
+
```ruby
|
65
|
+
class Hero
|
66
|
+
include Domainic::Attributer
|
67
|
+
|
68
|
+
argument :name # Required, must be first
|
69
|
+
argument :power # Required, must be second
|
70
|
+
option :catchphrase # Optional, can be provided by name
|
71
|
+
option :sidekick # Optional, can be provided by name
|
72
|
+
end
|
73
|
+
|
74
|
+
# All valid ways to create a hero:
|
75
|
+
Hero.new("Spider-Man", "Web-slinging", catchphrase: "With great power...")
|
76
|
+
Hero.new("Batman", "Being rich", sidekick: "Robin")
|
77
|
+
Hero.new("Wonder Woman", "Super strength")
|
78
|
+
```
|
79
|
+
|
80
|
+
#### Argument Ordering and Default Values
|
81
|
+
|
82
|
+
Arguments in Domainic::Attributer follow special ordering rules based on whether they have defaults:
|
83
|
+
|
84
|
+
* Arguments without defaults are required and are automatically moved to the front of the argument list
|
85
|
+
* Arguments with defaults are optional and are moved to the end of the argument list
|
86
|
+
* Within each group (with/without defaults), arguments maintain their order of declaration
|
87
|
+
|
88
|
+
This means the actual position when providing arguments to the constructor will be different from their declaration
|
89
|
+
order:
|
90
|
+
|
91
|
+
```ruby
|
92
|
+
class EmailMessage
|
93
|
+
include Domainic::Attributer
|
94
|
+
|
95
|
+
# This will be the first argument (no default)
|
96
|
+
argument :to
|
97
|
+
|
98
|
+
# This will be the third argument (has default)
|
99
|
+
argument :priority, default: :normal
|
100
|
+
|
101
|
+
# This will be the second argument (no default)
|
102
|
+
argument :subject
|
103
|
+
end
|
104
|
+
|
105
|
+
# Arguments must be provided in their sorted order,
|
106
|
+
# with required arguments first:
|
107
|
+
EmailMessage.new("user@example.com", "Welcome!", :high)
|
108
|
+
# => #<EmailMessage:0x00007f9b1b8b3b10 @to="user@example.com", @priority=:high, @subject="Welcome!">
|
109
|
+
|
110
|
+
# If you try to provide the arguments in their declaration order, you'll get undesired results:
|
111
|
+
EmailMessage.new("user@example.com", :high, "Welcome!")
|
112
|
+
# => #<EmailMessage:0x00007f9b1b8b3b10 @to="user@example.com", @priority="Welcome!", @subject=:high>
|
113
|
+
```
|
114
|
+
|
115
|
+
This behavior ensures that required arguments are provided first and optional arguments (those with defaults) come
|
116
|
+
after, making argument handling more predictable. You can rely on this ordering regardless of how you declare the
|
117
|
+
arguments in your class. Best practice is to declare arguments without defaults first, followed by those with defaults.
|
118
|
+
|
119
|
+
### Nilability And Option Requirements
|
120
|
+
|
121
|
+
Be explicit about nil values:
|
122
|
+
|
123
|
+
```ruby
|
124
|
+
class User
|
125
|
+
include Domainic::Attributer
|
126
|
+
|
127
|
+
argument :email do
|
128
|
+
non_nilable # or not_null, non_null, etc.
|
129
|
+
end
|
130
|
+
|
131
|
+
option :nickname do
|
132
|
+
default nil # Explicitly allow nil
|
133
|
+
end
|
134
|
+
end
|
135
|
+
```
|
136
|
+
|
137
|
+
Ensure certain options are always provided:
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
class Order
|
141
|
+
include Domainic::Attributer
|
142
|
+
|
143
|
+
option :items, required: true
|
144
|
+
option :status, Symbol
|
145
|
+
end
|
146
|
+
|
147
|
+
Order.new(option: ['item1', 'item2']) # OK
|
148
|
+
Order.new(status: :pending) # Raises ArgumentError
|
149
|
+
```
|
150
|
+
|
151
|
+
#### Required vs NonNilable
|
152
|
+
|
153
|
+
`required` and `non_nilable` are similar but not identical. `required` means the option must be provided when the object
|
154
|
+
is created, while `non_nilable` means the option must not be nil. A `required` option can still be nil if it's provided.
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
class User
|
158
|
+
include Domainic::Attributer
|
159
|
+
|
160
|
+
option :email, String do
|
161
|
+
required
|
162
|
+
non_nilable
|
163
|
+
end
|
164
|
+
|
165
|
+
option :nickname, String do
|
166
|
+
required
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
User.new(email: 'example@example.com', nickname: nil) # OK
|
171
|
+
User.new(email: nil, nickname: 'example') # Raises ArgumentError because email is non_nilable
|
172
|
+
User.new(email: 'example@example.com') # Raises ArgumentError because nickname is required
|
173
|
+
|
174
|
+
user = User.new(email: 'example@example.com', nickname: 'example')
|
175
|
+
user.nickname = nil # OK
|
176
|
+
user.email = nil # Raises ArgumentError because email is non_nilable
|
177
|
+
```
|
178
|
+
|
179
|
+
### Type Validation
|
180
|
+
|
181
|
+
Keep your data clean with built-in type validation:
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
class BankAccount
|
185
|
+
include Domainic::Attributer
|
186
|
+
|
187
|
+
argument :account_name, String # Direct class validation
|
188
|
+
argument :opened_at, Time # Another direct class example
|
189
|
+
option :balance, Integer, default: 0 # Combining class validation with defaults
|
190
|
+
option :status, ->(val) { [:active, :closed].include?(val) } # Custom validation
|
191
|
+
end
|
192
|
+
|
193
|
+
# Will raise ArgumentError:
|
194
|
+
BankAccount.new(:my_account_name, Time.now)
|
195
|
+
BankAccount.new("my_account_name", "not a time")
|
196
|
+
BankAccount.new("my_account_name", Time.now, balance: "not an integer")
|
197
|
+
BankAccount.new("my_account_name", Time.now, balance: 100, status: :not_included_in_the_allow_list)
|
198
|
+
```
|
199
|
+
|
200
|
+
### Documentation
|
201
|
+
|
202
|
+
Make your attributes self-documenting:
|
203
|
+
|
204
|
+
```ruby
|
205
|
+
class Car
|
206
|
+
include Domainic::Attributer
|
207
|
+
|
208
|
+
argument :make, String do
|
209
|
+
desc "The make of the car"
|
210
|
+
end
|
211
|
+
|
212
|
+
argument :model, String do
|
213
|
+
description "The model of the car"
|
214
|
+
end
|
215
|
+
|
216
|
+
argument :year, ->(value) { value.is_a?(Integer) && value >= 1900 && value <= Time.now.year } do
|
217
|
+
description "The year the car was made"
|
218
|
+
end
|
219
|
+
end
|
220
|
+
```
|
221
|
+
|
222
|
+
### Value Coercion
|
223
|
+
|
224
|
+
Transform input values automatically:
|
225
|
+
|
226
|
+
```ruby
|
227
|
+
class Temperature
|
228
|
+
include Domainic::Attributer
|
229
|
+
|
230
|
+
argument :celsius do |value|
|
231
|
+
coerce_with ->(val) { val.to_f }
|
232
|
+
validate_with ->(val) { val.is_a?(Float) }
|
233
|
+
end
|
234
|
+
|
235
|
+
option :unit, default: "C" do |value|
|
236
|
+
validate_with ->(val) { ["C", "F"].include?(val) }
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
temp = Temperature.new("24.5") # Automatically converted to Float
|
241
|
+
temp.celsius # => 24.5
|
242
|
+
```
|
243
|
+
|
244
|
+
### Custom Validation
|
245
|
+
|
246
|
+
Domainic::Attributer provides flexible validation options that can be combined to create sophisticated validation rules.
|
247
|
+
You can:
|
248
|
+
|
249
|
+
* Use Ruby classes directly to validate types
|
250
|
+
* Use Procs/lambdas for custom validation logic
|
251
|
+
* Chain multiple validations
|
252
|
+
* Combine validations with coercions
|
253
|
+
|
254
|
+
```ruby
|
255
|
+
class BankTransfer
|
256
|
+
include Domainic::Attributer
|
257
|
+
|
258
|
+
# Combine coercion and multiple validations
|
259
|
+
argument :amount do
|
260
|
+
coerce_with ->(val) { val.to_f } # First coerce to float
|
261
|
+
validate_with Float # Then validate it's a float
|
262
|
+
validate_with ->(val) { val.positive? } # And validate it's positive
|
263
|
+
end
|
264
|
+
|
265
|
+
# Different validation styles
|
266
|
+
argument :status do
|
267
|
+
validate_with Symbol # Must be a Symbol
|
268
|
+
validate_with ->(val) { [:pending, :completed, :failed].include?(val) } # Must be one of these values
|
269
|
+
end
|
270
|
+
|
271
|
+
# Validation with custom error handling
|
272
|
+
argument :reference_number do
|
273
|
+
validate_with ->(val) {
|
274
|
+
raise ArgumentError, "Reference must be 8 characters" unless val.length == 8
|
275
|
+
true
|
276
|
+
}
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
# These will work:
|
281
|
+
BankTransfer.new("50.0", :pending, "12345678") # amount coerced to 50.0
|
282
|
+
BankTransfer.new(75.25, :completed, "ABCD1234") # amount already a float
|
283
|
+
|
284
|
+
# These will raise ArgumentError:
|
285
|
+
BankTransfer.new(-10, :pending, "12345678") # amount must be positive
|
286
|
+
BankTransfer.new(100, :invalid, "12345678") # invalid status
|
287
|
+
BankTransfer.new(100, :pending, "123") # invalid reference number
|
288
|
+
```
|
289
|
+
|
290
|
+
Validations are run in the order they're defined, after any coercions. This lets you build up complex validation rules
|
291
|
+
while keeping them readable and maintainable.
|
292
|
+
|
293
|
+
### Visibility Control
|
294
|
+
|
295
|
+
Control access to your attributes:
|
296
|
+
|
297
|
+
```ruby
|
298
|
+
class SecretAgent
|
299
|
+
include Domainic::Attributer
|
300
|
+
|
301
|
+
argument :code_name
|
302
|
+
option :real_name do
|
303
|
+
private_read # Can't read real_name from outside
|
304
|
+
private_write # Can't write real_name from outside
|
305
|
+
end
|
306
|
+
option :mission do
|
307
|
+
protected # Both read and write are protected
|
308
|
+
end
|
309
|
+
end
|
310
|
+
```
|
311
|
+
|
312
|
+
### Change Callbacks
|
313
|
+
|
314
|
+
React to attribute changes:
|
315
|
+
|
316
|
+
```ruby
|
317
|
+
class Thermostat
|
318
|
+
include Domainic::Attributer
|
319
|
+
|
320
|
+
option :temperature do
|
321
|
+
default 20
|
322
|
+
on_change ->(old_val, new_val) {
|
323
|
+
puts "Temperature changing from #{old_val}°C to #{new_val}°C"
|
324
|
+
}
|
325
|
+
end
|
326
|
+
end
|
327
|
+
```
|
328
|
+
|
329
|
+
### Default Values
|
330
|
+
|
331
|
+
Provide static defaults or generate them dynamically:
|
332
|
+
|
333
|
+
```ruby
|
334
|
+
class Order
|
335
|
+
include Domainic::Attributer
|
336
|
+
|
337
|
+
argument :items
|
338
|
+
option :created_at do
|
339
|
+
default { Time.now } # Dynamic default
|
340
|
+
end
|
341
|
+
option :status do
|
342
|
+
default "pending" # Static default
|
343
|
+
end
|
344
|
+
end
|
345
|
+
```
|
346
|
+
|
347
|
+
### Custom Method Names
|
348
|
+
|
349
|
+
Don't like `argument` and `option`? Create your own interface:
|
350
|
+
|
351
|
+
```ruby
|
352
|
+
class Configuration
|
353
|
+
include Domainic.Attributer(argument: :param, option: :setting)
|
354
|
+
|
355
|
+
param :environment
|
356
|
+
setting :debug_mode, default: false
|
357
|
+
end
|
358
|
+
```
|
359
|
+
|
360
|
+
or turn off one of the methods entirely:
|
361
|
+
|
362
|
+
```ruby
|
363
|
+
class Configuration
|
364
|
+
include Domainic.Attributer(argument: nil)
|
365
|
+
|
366
|
+
option :environment
|
367
|
+
end
|
368
|
+
```
|
369
|
+
|
370
|
+
### Serialization
|
371
|
+
|
372
|
+
Convert your objects to hashes easily:
|
373
|
+
|
374
|
+
```ruby
|
375
|
+
class Product
|
376
|
+
include Domainic::Attributer
|
377
|
+
|
378
|
+
argument :name
|
379
|
+
argument :price
|
380
|
+
option :description, default: ""
|
381
|
+
option :internal_id do
|
382
|
+
private # Won't be included in to_h output
|
383
|
+
end
|
384
|
+
end
|
385
|
+
|
386
|
+
product = Product.new("Widget", 9.99, description: "A fantastic widget")
|
387
|
+
product.to_h # => { name: "Widget", price: 9.99, description: "A fantastic widget" }
|
388
|
+
```
|
389
|
+
|
390
|
+
## Contributing
|
391
|
+
|
392
|
+
Bug reports and pull requests are welcome on GitHub.
|
393
|
+
|
394
|
+
## License
|
395
|
+
|
396
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE).
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'domainic/attributer/attribute/mixin/belongs_to_attribute'
|
4
|
+
|
5
|
+
module Domainic
|
6
|
+
module Attributer
|
7
|
+
class Attribute
|
8
|
+
# A class responsible for managing change callbacks for an attribute.
|
9
|
+
#
|
10
|
+
# This class handles the execution of callbacks that are triggered when an
|
11
|
+
# attribute's value changes. Each callback must be a Proc that accepts two
|
12
|
+
# arguments: the old value and the new value.
|
13
|
+
#
|
14
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
15
|
+
# @since 0.1.0
|
16
|
+
class Callback
|
17
|
+
# @rbs!
|
18
|
+
# type handler = ^(untyped old_value, untyped new_value) -> void | Proc
|
19
|
+
|
20
|
+
# @rbs @handlers: Array[handler]
|
21
|
+
|
22
|
+
include BelongsToAttribute
|
23
|
+
|
24
|
+
# Initialize a new Callback instance.
|
25
|
+
#
|
26
|
+
# @param attribute [Attribute] the attribute this Callback belongs to
|
27
|
+
# @param handlers [Array<Proc>] the handlers to use for processing
|
28
|
+
#
|
29
|
+
# @return [Callback] the new instance of Callback
|
30
|
+
# @rbs (Attribute attribute, Array[handler] | handler handlers) -> void
|
31
|
+
def initialize(attribute, handlers = [])
|
32
|
+
super
|
33
|
+
@handlers = [*handlers].map do |handler|
|
34
|
+
validate_handler!(handler)
|
35
|
+
handler
|
36
|
+
end.uniq
|
37
|
+
end
|
38
|
+
|
39
|
+
# Execute all callbacks for a value change.
|
40
|
+
#
|
41
|
+
# @param instance [Object] the instance on which to execute callbacks
|
42
|
+
# @param old_value [Object] the previous value
|
43
|
+
# @param new_value [Object] the new value
|
44
|
+
#
|
45
|
+
# @return [void]
|
46
|
+
# @rbs (untyped instance, untyped old_value, untyped new_value) -> void
|
47
|
+
def call(instance, old_value, new_value)
|
48
|
+
@handlers.each { |handler| instance.instance_exec(old_value, new_value, &handler) }
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
# Validate that a callback handler is a valid Proc.
|
54
|
+
#
|
55
|
+
# @param handler [Object] the handler to validate
|
56
|
+
#
|
57
|
+
# @raise [TypeError] if the handler is not a valid Proc
|
58
|
+
# @return [void]
|
59
|
+
# @rbs (handler handler) -> void
|
60
|
+
def validate_handler!(handler)
|
61
|
+
return if handler.is_a?(Proc)
|
62
|
+
|
63
|
+
raise TypeError, "`#{attribute_method_name}`: invalid handler: #{handler.inspect}. Must be a Proc."
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'domainic/attributer/attribute/mixin/belongs_to_attribute'
|
4
|
+
|
5
|
+
module Domainic
|
6
|
+
module Attributer
|
7
|
+
class Attribute
|
8
|
+
# A class responsible for coercing attribute values.
|
9
|
+
#
|
10
|
+
# This class manages the coercion of values assigned to an attribute. Coercion can be
|
11
|
+
# handled by either a Proc that accepts a single value argument, or by referencing an
|
12
|
+
# instance method via Symbol.
|
13
|
+
#
|
14
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
15
|
+
# @since 0.1.0
|
16
|
+
class Coercer
|
17
|
+
# @rbs!
|
18
|
+
# type handler = proc | Proc | Symbol
|
19
|
+
#
|
20
|
+
# type proc = ^(untyped value) -> untyped
|
21
|
+
|
22
|
+
include BelongsToAttribute
|
23
|
+
|
24
|
+
# @rbs @handlers: Array[handler]
|
25
|
+
|
26
|
+
# Initialize a new Coercer instance.
|
27
|
+
#
|
28
|
+
# @param attribute [Attribute] the attribute this Coercer belongs to
|
29
|
+
# @param handlers [Array<Proc, Symbol>] the handlers to use for processing
|
30
|
+
#
|
31
|
+
# @return [Coercer] the new instance of Coercer
|
32
|
+
# @rbs (Attribute attribute, Array[handler] | handler handlers) -> void
|
33
|
+
def initialize(attribute, handlers = [])
|
34
|
+
super
|
35
|
+
@handlers = [*handlers].map do |handler|
|
36
|
+
validate_handler!(handler)
|
37
|
+
handler
|
38
|
+
end.uniq
|
39
|
+
end
|
40
|
+
|
41
|
+
# Process a value through all coercion handlers.
|
42
|
+
#
|
43
|
+
# @param instance [Object] the instance on which to perform coercion
|
44
|
+
# @param value [Object] the value to coerce
|
45
|
+
#
|
46
|
+
# @return [Object] the coerced value
|
47
|
+
# @rbs (untyped instance, untyped value) -> untyped
|
48
|
+
def call(instance, value)
|
49
|
+
@handlers.reduce(value) { |accumulator, handler| coerce_value(instance, handler, accumulator) }
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
# Process a value through a single coercion handler.
|
55
|
+
#
|
56
|
+
# @param instance [Object] the instance on which to perform coercion
|
57
|
+
# @param handler [Proc, Symbol] the coercion handler
|
58
|
+
# @param value [Object] the value to coerce
|
59
|
+
#
|
60
|
+
# @raise [TypeError] if the handler is invalid
|
61
|
+
# @return [Object] the coerced value
|
62
|
+
# @rbs (untyped instance, handler, untyped value) -> untyped
|
63
|
+
def coerce_value(instance, handler, value)
|
64
|
+
case handler
|
65
|
+
when Proc
|
66
|
+
instance.instance_exec(value, &handler)
|
67
|
+
when Symbol
|
68
|
+
instance.send(handler, value)
|
69
|
+
else
|
70
|
+
# We should never get here because we validate the handlers in the initializer.
|
71
|
+
raise TypeError, "`#{attribute_method_name}`: invalid coercer: #{handler}. "
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Validate that a coercion handler is valid.
|
76
|
+
#
|
77
|
+
# @param handler [Object] the handler to validate
|
78
|
+
#
|
79
|
+
# @raise [TypeError] if the handler is not valid
|
80
|
+
# @return [void]
|
81
|
+
# @rbs (handler handler) -> void
|
82
|
+
def validate_handler!(handler)
|
83
|
+
return if handler.is_a?(Proc)
|
84
|
+
return if handler.is_a?(Symbol) &&
|
85
|
+
(@attribute.base.method_defined?(handler) || @attribute.base.private_method_defined?(handler))
|
86
|
+
|
87
|
+
raise TypeError, "`#{attribute_method_name}`: invalid coercer: #{handler.inspect}. Must be a Proc " \
|
88
|
+
'or a Symbol referencing a method.'
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Domainic
|
4
|
+
module Attributer
|
5
|
+
class Attribute
|
6
|
+
# A mixin providing common functionality for classes that belong to an Attribute.
|
7
|
+
#
|
8
|
+
# This module provides initialization and duplication behavior for classes that are owned
|
9
|
+
# by and work in conjunction with an Attribute instance. These classes typically handle
|
10
|
+
# specific aspects of attribute processing such as coercion, validation, or callbacks.
|
11
|
+
#
|
12
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
13
|
+
# @since 0.1.0
|
14
|
+
module BelongsToAttribute
|
15
|
+
# @rbs @attribute: Attribute
|
16
|
+
|
17
|
+
# Initialize a new instance that belongs to an attribute.
|
18
|
+
#
|
19
|
+
# @param attribute [Attribute] the attribute this instance belongs to
|
20
|
+
#
|
21
|
+
# @return [void]
|
22
|
+
# @rbs (Attribute attribute, *untyped, **untyped) -> void
|
23
|
+
def initialize(attribute, ...)
|
24
|
+
validate_attribute!(attribute)
|
25
|
+
@attribute = attribute
|
26
|
+
end
|
27
|
+
|
28
|
+
# Create a duplicate instance associated with a new attribute.
|
29
|
+
#
|
30
|
+
# @param new_attribute [Attribute] the new attribute to associate with
|
31
|
+
#
|
32
|
+
# @return [BelongsToAttribute] a duplicate instance
|
33
|
+
# @rbs (Attribute attribute) -> BelongsToAttribute
|
34
|
+
def dup_with_attribute(new_attribute)
|
35
|
+
validate_attribute!(new_attribute)
|
36
|
+
|
37
|
+
dup.tap { |duped| duped.instance_variable_set(:@attribute, new_attribute) }
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
# Generate a method name for error messages.
|
43
|
+
#
|
44
|
+
# @return [String] the formatted method name
|
45
|
+
# @rbs () -> String
|
46
|
+
def attribute_method_name
|
47
|
+
"#{@attribute.base}##{@attribute.name}"
|
48
|
+
end
|
49
|
+
|
50
|
+
# Ensure that an attribute is a valid {Attribute} instance.
|
51
|
+
#
|
52
|
+
# @param attribute [Attribute] the attribute to validate
|
53
|
+
#
|
54
|
+
# @raise [TypeError] if the attribute is not a valid {Attribute} instance
|
55
|
+
# @return [void]
|
56
|
+
# @rbs (Attribute attribute) -> void
|
57
|
+
def validate_attribute!(attribute)
|
58
|
+
return if attribute.is_a?(Attribute)
|
59
|
+
return if defined?(RSpec::Mocks::TestDouble) && attribute.is_a?(RSpec::Mocks::TestDouble)
|
60
|
+
|
61
|
+
raise TypeError,
|
62
|
+
"invalid attribute: #{attribute.inspect}. Must be an Domainic::Attributer::Attribute instance"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
private_constant :BelongsToAttribute
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|