lab42_data_class 0.5.0 → 0.7.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/README.md +86 -358
- data/lib/lab42/data_class/duplicate_definition_error.rb +9 -0
- data/lib/lab42/data_class/kernel.rb +17 -0
- data/lib/lab42/data_class/proxy/constraints/maker.rb +2 -0
- data/lib/lab42/data_class/proxy/constraints.rb +12 -8
- data/lib/lab42/data_class/proxy/derived.rb +21 -0
- data/lib/lab42/data_class/proxy/memos.rb +52 -0
- data/lib/lab42/data_class/proxy.rb +66 -29
- data/lib/lab42/data_class/version.rb +1 -1
- data/lib/lab42/data_class.rb +36 -11
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ae30c490e9b798403214f49c4cbc43564bf47b741878f9aa174e27dc17a85b8
|
|
4
|
+
data.tar.gz: 2e7ab04142e8eb38206ba14bd03464465844b307087708d382e253fc40dd8f1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f40faa136f73115809cea4f73d722188ea62e9299319313b1ffcbcc852c710de0c399c3d1c54dcdb1ae1de57173baabfb66607f636ff62b747f133774e4c205a
|
|
7
|
+
data.tar.gz: 294c975087b12a3cab2731eb78840d735dbdeac923f1c2edb8d5beaf598848caf18d571e3129b42dac0ad2d276657507f4df10268a41863ffb2150ea11df5d8a
|
data/README.md
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
|
|
2
|
+
|
|
2
3
|
[](https://codeclimate.com/github/RobertDober/lab42_data_class)
|
|
3
4
|
[](https://github.com/robertdober/lab42_data_class/actions)
|
|
4
5
|
[](https://coveralls.io/github/RobertDober/lab42_data_class?branch=main)
|
|
@@ -11,9 +12,31 @@
|
|
|
11
12
|
|
|
12
13
|
An Immutable DataClass for Ruby
|
|
13
14
|
|
|
14
|
-
Exposes a class factory function `Kernel::DataClass` and a
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
Exposes a class factory function `Kernel::DataClass` and a module `Lab42::DataClass` which can
|
|
16
|
+
extend classes to become _Data Classes_.
|
|
17
|
+
|
|
18
|
+
Also exposes two _tuple_ classes, `Pair` and `Triple`
|
|
19
|
+
|
|
20
|
+
## Synopsis
|
|
21
|
+
|
|
22
|
+
Having immutable Objects has many well known advantages that I will not ponder upon in detail here.
|
|
23
|
+
|
|
24
|
+
One advantage which is of particular interest though is that, as every, _modification_ is in fact the
|
|
25
|
+
creation of a new object **strong contraints** on the data can **easily** be maintained, and this
|
|
26
|
+
library makes that available to the user.
|
|
27
|
+
|
|
28
|
+
Therefore we can summarise the features (or not so features, that is for you to decide and you to chose to use or not):
|
|
29
|
+
|
|
30
|
+
- Immutable with an Interface à la `OpenStruct`
|
|
31
|
+
- Attributes are predefined and can have **default values**
|
|
32
|
+
- Construction with _keyword arguments_, **exclusively**
|
|
33
|
+
- Conversion to `Hash` instances (if you must)
|
|
34
|
+
- Pattern matching exactly like `Hash` instances
|
|
35
|
+
- Possibility to impose **strong constraints** on attributes
|
|
36
|
+
- Predefined constraints and concise syntax for constraints
|
|
37
|
+
- Possibility to impose **arbitrary validation** (constraints on the whole object)
|
|
38
|
+
- Declaration of **dependent attributes** which are memoized (thank you _Immutability_)
|
|
39
|
+
- Inheritance with **mixin of other dataclasses** (multiple if you must)
|
|
17
40
|
|
|
18
41
|
## Usage
|
|
19
42
|
|
|
@@ -33,424 +56,129 @@ In your code
|
|
|
33
56
|
require 'lab42/data_class'
|
|
34
57
|
```
|
|
35
58
|
|
|
59
|
+
## Speculations (literate specs)
|
|
36
60
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
Well let us [speculate about](https://github.com/RobertDober/speculate_about) it to find out:
|
|
40
|
-
|
|
41
|
-
## Context `DataClass`
|
|
42
|
-
|
|
43
|
-
### Context: `DataClass` function
|
|
44
|
-
|
|
45
|
-
Given
|
|
46
|
-
```ruby
|
|
47
|
-
let(:my_data_class) { DataClass(:name, email: nil) }
|
|
48
|
-
let(:my_instance) { my_data_class.new(name: "robert") }
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
Then we can access its fields
|
|
52
|
-
```ruby
|
|
53
|
-
expect(my_instance.name).to eq("robert")
|
|
54
|
-
expect(my_instance.email).to be_nil
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
But we cannot access undefined fields
|
|
58
|
-
```ruby
|
|
59
|
-
expect{ my_instance.undefined }.to raise_error(NoMethodError)
|
|
60
|
-
```
|
|
61
|
+
The following specs are executed with the [speculate about](https://github.com/RobertDober/speculate_about) gem.
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
```ruby
|
|
64
|
-
expect{ my_data_class.new(email: "some@mail.org") }
|
|
65
|
-
.to raise_error(ArgumentError, "missing initializers for [:name]")
|
|
66
|
-
```
|
|
67
|
-
And we can extract the values
|
|
63
|
+
Given that we have imported the `Lab42` namespace
|
|
68
64
|
```ruby
|
|
69
|
-
|
|
65
|
+
DataClass = Lab42::DataClass
|
|
70
66
|
```
|
|
71
67
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
Then `my_instance` is frozen:
|
|
75
|
-
```ruby
|
|
76
|
-
expect(my_instance).to be_frozen
|
|
77
|
-
```
|
|
78
|
-
And we cannot even mute `my_instance` by means of metaprogramming
|
|
79
|
-
```ruby
|
|
80
|
-
expect{ my_instance.instance_variable_set("@x", nil) }.to raise_error(FrozenError)
|
|
81
|
-
```
|
|
68
|
+
## Context: Data Classes
|
|
82
69
|
|
|
83
|
-
|
|
70
|
+
### Basic Use Case
|
|
84
71
|
|
|
85
|
-
Given
|
|
72
|
+
Given a simple Data Class
|
|
86
73
|
```ruby
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
```ruby
|
|
91
|
-
expect(other_instance.to_h).to eq(name: "robert", email: "robert@mail.provider")
|
|
92
|
-
expect(my_instance.to_h).to eq(name: "robert", email: nil)
|
|
93
|
-
```
|
|
94
|
-
And the new instance is frozen again
|
|
95
|
-
```ruby
|
|
96
|
-
expect(other_instance).to be_frozen
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
### Context: Defining behavior with blocks
|
|
100
|
-
|
|
101
|
-
Given
|
|
102
|
-
```ruby
|
|
103
|
-
let :my_data_class do
|
|
104
|
-
DataClass :value, prefix: "<", suffix: ">" do
|
|
105
|
-
def show
|
|
106
|
-
[prefix, value, suffix].join
|
|
107
|
-
end
|
|
108
|
-
end
|
|
74
|
+
class SimpleDataClass
|
|
75
|
+
extend DataClass
|
|
76
|
+
attributes :a, :b
|
|
109
77
|
end
|
|
110
|
-
let(:my_instance) { my_data_class.new(value: 42) }
|
|
111
78
|
```
|
|
112
79
|
|
|
113
|
-
|
|
80
|
+
And an instance of it
|
|
114
81
|
```ruby
|
|
115
|
-
|
|
82
|
+
let(:simple_instance) { SimpleDataClass.new(a: 1, b: 2) }
|
|
116
83
|
```
|
|
117
84
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
Given two instances of a DataClass
|
|
121
|
-
```ruby
|
|
122
|
-
let(:data_class) { DataClass :a }
|
|
123
|
-
let(:instance1) { data_class.new(a: 1) }
|
|
124
|
-
let(:instance2) { data_class.new(a: 1) }
|
|
125
|
-
```
|
|
126
|
-
Then they are equal in the sense of `==` and `eql?`
|
|
85
|
+
Then we access the fields
|
|
127
86
|
```ruby
|
|
128
|
-
expect(
|
|
129
|
-
expect(
|
|
130
|
-
expect(instance1 == instance2).to be_truthy
|
|
131
|
-
expect(instance2 == instance1).to be_truthy
|
|
87
|
+
expect(simple_instance.a).to eq(1)
|
|
88
|
+
expect(simple_instance.b).to eq(2)
|
|
132
89
|
```
|
|
133
|
-
But not in the sense of `equal?`, of course
|
|
134
|
-
```ruby
|
|
135
|
-
expect(instance1).not_to be_equal(instance2)
|
|
136
|
-
expect(instance2).not_to be_equal(instance1)
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
#### Context: Immutability of `dataclass` modified classes
|
|
140
90
|
|
|
141
|
-
|
|
91
|
+
And we convert to a hash
|
|
142
92
|
```ruby
|
|
143
|
-
expect(
|
|
93
|
+
expect(simple_instance.to_h).to eq(a: 1, b: 2)
|
|
144
94
|
```
|
|
145
95
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
... is a no, we do not want inheritance although we **like** code reuse, how to do it then?
|
|
149
|
-
|
|
150
|
-
Well there shall be many different possibilities, depending on your style, use case and
|
|
151
|
-
context, here is just one example:
|
|
152
|
-
|
|
153
|
-
Given a class factory
|
|
96
|
+
And we can derive new instances
|
|
154
97
|
```ruby
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
end
|
|
159
|
-
end
|
|
98
|
+
new_instance = simple_instance.merge(b: 3)
|
|
99
|
+
expect(new_instance.to_h).to eq(a: 1, b: 3)
|
|
100
|
+
expect(simple_instance.to_h).to eq(a: 1, b: 2)
|
|
160
101
|
```
|
|
161
102
|
|
|
162
|
-
|
|
163
|
-
```ruby
|
|
164
|
-
empty = token.()
|
|
165
|
-
integer = token.(:value)
|
|
166
|
-
boolean = token.(value: false)
|
|
103
|
+
For detailed speculations please see [here](speculations/DATA_CLASSES.md)
|
|
167
104
|
|
|
168
|
-
|
|
169
|
-
expect(integer.new(value: -1).to_h).to eq(text: "", value: -1)
|
|
170
|
-
expect(boolean.new.value).to eq(false)
|
|
171
|
-
```
|
|
105
|
+
## Context: `DataClass` function
|
|
172
106
|
|
|
173
|
-
|
|
107
|
+
As seen in the speculations above it seems appropriate to declare a `Class` and
|
|
108
|
+
extend it as we will add quite some code for constraints, derived attributes and validations.
|
|
174
109
|
|
|
175
|
-
|
|
176
|
-
```ruby
|
|
177
|
-
module Humanize
|
|
178
|
-
def humanize
|
|
179
|
-
"my value is #{value}"
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
let(:class_level) { DataClass(value: 1).include(Humanize) }
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
Then we can access the included method
|
|
187
|
-
```ruby
|
|
188
|
-
expect(class_level.new.humanize).to eq("my value is 1")
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
### Context: Pattern Matching
|
|
110
|
+
However a more concise _Factory Function_ might still be very useful in some use cases...
|
|
192
111
|
|
|
193
|
-
|
|
112
|
+
Enter `Kernel::DataClass` **The Function**
|
|
194
113
|
|
|
195
|
-
|
|
196
|
-
```ruby
|
|
197
|
-
let(:numbers) { DataClass(:name, values: []) }
|
|
198
|
-
let(:odds) { numbers.new(name: "odds", values: (1..4).map{ _1 + _1 + 1}) }
|
|
199
|
-
let(:evens) { numbers.new(name: "evens", values: (1..4).map{ _1 + _1}) }
|
|
200
|
-
```
|
|
114
|
+
### Context: Just Attributes
|
|
201
115
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
match = case odds
|
|
205
|
-
in {name: "odds", values: [1, *]}
|
|
206
|
-
:not_really
|
|
207
|
-
in {name: "evens"}
|
|
208
|
-
:still_naaah
|
|
209
|
-
in {name: "odds", values: [hd, *]}
|
|
210
|
-
hd
|
|
211
|
-
else
|
|
212
|
-
:strange
|
|
213
|
-
end
|
|
214
|
-
expect(match).to eq(3)
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
And in `in` expressions
|
|
218
|
-
```ruby
|
|
219
|
-
evens => {values: [_, second, *]}
|
|
220
|
-
expect(second).to eq(4)
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
#### Context: In Case Statements
|
|
224
|
-
|
|
225
|
-
Given a nice little dataclass `Box`
|
|
226
|
-
```ruby
|
|
227
|
-
let(:box) { DataClass content: nil }
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
Then we can also use it in a case statement
|
|
231
|
-
```ruby
|
|
232
|
-
value = case box.new
|
|
233
|
-
when box
|
|
234
|
-
42
|
|
235
|
-
else
|
|
236
|
-
0
|
|
237
|
-
end
|
|
238
|
-
expect(value).to eq(42)
|
|
239
|
-
```
|
|
116
|
+
If there are no _Constraints_, _Derived Attributes_, _Validation_ or _Inheritance_ this concise syntax
|
|
117
|
+
might easily be preferred by many:
|
|
240
118
|
|
|
241
|
-
|
|
119
|
+
Given some example instances like these
|
|
242
120
|
```ruby
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
### Context: Behaving like a `Proc`
|
|
248
|
-
|
|
249
|
-
It is useful to be able to filter heterogeneous lists of `DataClass` instances by means of `&to_proc`, therefore
|
|
250
|
-
|
|
251
|
-
Given two different `DataClass` objects
|
|
252
|
-
```ruby
|
|
253
|
-
let(:class1) { DataClass :value }
|
|
254
|
-
let(:class2) { DataClass :value }
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
And a list of instances
|
|
258
|
-
```ruby
|
|
259
|
-
let(:list) {[class1.new(value: 1), class2.new(value: 2), class1.new(value: 3)]}
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
Then we can filter
|
|
263
|
-
```ruby
|
|
264
|
-
expect(list.filter(&class2)).to eq([class2.new(value: 2)])
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
### Context: Behaving like a `Hash`
|
|
268
|
-
|
|
269
|
-
We have already seen the `to_h` method, however if we want to pass an instance of `DataClass` as
|
|
270
|
-
keyword parameters we need an implementation of `to_hash`, which of course is just an alias
|
|
271
|
-
|
|
272
|
-
Given this keyword method
|
|
273
|
-
```ruby
|
|
274
|
-
def extract_value(value:, **others)
|
|
275
|
-
[value, others]
|
|
276
|
-
end
|
|
277
|
-
```
|
|
278
|
-
And this `DataClass`:
|
|
279
|
-
```ruby
|
|
280
|
-
let(:my_class) { DataClass(value: 1, base: 2) }
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
Then we can pass it as keyword arguments
|
|
284
|
-
```ruby
|
|
285
|
-
expect(extract_value(**my_class.new)).to eq([1, base: 2])
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
### Context: Constraints
|
|
289
|
-
|
|
290
|
-
Values of attributes of a `DataClass` can have constraints
|
|
291
|
-
|
|
292
|
-
Given a `DataClass` with constraints
|
|
293
|
-
```ruby
|
|
294
|
-
let :switch do
|
|
295
|
-
DataClass(on: false).with_constraint(on: -> { [false, true].member? _1 })
|
|
296
|
-
end
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
Then boolean values are acceptable
|
|
300
|
-
```ruby
|
|
301
|
-
expect{ switch.new }.not_to raise_error
|
|
302
|
-
expect(switch.new.merge(on: true).on).to eq(true)
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
But we can neither construct or merge with non boolean values
|
|
306
|
-
```ruby
|
|
307
|
-
expect{ switch.new(on: nil) }
|
|
308
|
-
.to raise_error(Lab42::DataClass::ConstraintError, "value nil is not allowed for attribute :on")
|
|
309
|
-
expect{ switch.new.merge(on: 42) }
|
|
310
|
-
.to raise_error(Lab42::DataClass::ConstraintError, "value 42 is not allowed for attribute :on")
|
|
121
|
+
let(:my_data_class) { DataClass(:name, email: nil) }
|
|
122
|
+
let(:my_instance) { my_data_class.new(name: "robert") }
|
|
311
123
|
```
|
|
312
124
|
|
|
313
|
-
|
|
125
|
+
Then we can access its fields
|
|
314
126
|
```ruby
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
error_message = [error_head, error_body].join("\n")
|
|
318
|
-
|
|
319
|
-
expect{ DataClass(value: nil).with_constraint(value: -> { _1 > 0 }) }
|
|
320
|
-
.to raise_error(Lab42::DataClass::ConstraintError, /#{error_message}/)
|
|
127
|
+
expect(my_instance.name).to eq("robert")
|
|
128
|
+
expect(my_instance[:email]).to be_nil
|
|
321
129
|
```
|
|
322
130
|
|
|
323
|
-
|
|
131
|
+
But we cannot access undefined fields
|
|
324
132
|
```ruby
|
|
325
|
-
expect
|
|
326
|
-
.to raise_error(ArgumentError, "constraints cannot be defined for undefined attributes [:b]")
|
|
133
|
+
expect{ my_instance.undefined }.to raise_error(NoMethodError)
|
|
327
134
|
```
|
|
328
135
|
|
|
329
|
-
|
|
330
|
-
#### Context: Convenience Constraints
|
|
331
|
-
|
|
332
|
-
Often repeating patterns are implemented as non lambda constraints, depending on the type of a constraint
|
|
333
|
-
it is implicitly converted to a lambda as specified below:
|
|
334
|
-
|
|
335
|
-
Given a shortcut for our `ConstraintError`
|
|
136
|
+
And this is even true for the `[]` syntax
|
|
336
137
|
```ruby
|
|
337
|
-
|
|
338
|
-
let(:positive) { DataClass(:value) }
|
|
138
|
+
expect{ my_instance[:undefined] }.to raise_error(KeyError)
|
|
339
139
|
```
|
|
340
140
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
... are sent to the value of the attribute, this is not very surprising of course ;)
|
|
344
|
-
|
|
345
|
-
Then a first implementation of `Positive`
|
|
141
|
+
And we need to provide values to fields without defaults
|
|
346
142
|
```ruby
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
expect(positive_by_symbol.new(value: 1).value).to eq(1)
|
|
350
|
-
expect{positive_by_symbol.new(value: 0)}.to raise_error(constraint_error)
|
|
143
|
+
expect{ my_data_class.new(email: "some@mail.org") }
|
|
144
|
+
.to raise_error(ArgumentError, "missing initializers for [:name]")
|
|
351
145
|
```
|
|
352
|
-
|
|
353
|
-
##### Arrays
|
|
354
|
-
|
|
355
|
-
... are also sent to the value of the attribute, this time we can provide paramaters
|
|
356
|
-
And we can implement a different form of `Positive`
|
|
146
|
+
And we can extract the values
|
|
357
147
|
```ruby
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
expect(positive_by_ary.new(value: 1).value).to eq(1)
|
|
361
|
-
expect{positive_by_ary.new(value: 0)}.to raise_error(constraint_error)
|
|
148
|
+
expect(my_instance.to_h).to eq(name: "robert", email: nil)
|
|
362
149
|
```
|
|
363
150
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
##### Membership
|
|
367
|
-
|
|
368
|
-
And this works with a `Set`
|
|
369
|
-
```ruby
|
|
370
|
-
positive_by_set = positive.with_constraint(value: Set.new([*1..10]))
|
|
371
|
-
|
|
372
|
-
expect(positive_by_set.new(value: 1).value).to eq(1)
|
|
373
|
-
expect{positive_by_set.new(value: 0)}.to raise_error(constraint_error)
|
|
374
|
-
```
|
|
151
|
+
#### Context: Immutable → self
|
|
375
152
|
|
|
376
|
-
|
|
153
|
+
Then `my_instance` is frozen:
|
|
377
154
|
```ruby
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
expect(positive_by_range.new(value: 1).value).to eq(1)
|
|
381
|
-
expect{positive_by_range.new(value: 0)}.to raise_error(constraint_error)
|
|
155
|
+
expect(my_instance).to be_frozen
|
|
382
156
|
```
|
|
383
|
-
|
|
384
|
-
##### Regexen
|
|
385
|
-
|
|
386
|
-
This seems quite obvious, and of course it works
|
|
387
|
-
|
|
388
|
-
Then we can also have a regex based constraint
|
|
157
|
+
And we cannot even mute `my_instance` by means of metaprogramming
|
|
389
158
|
```ruby
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
expect(vowel.new(word: "alpha").word).to eq("alpha")
|
|
393
|
-
expect{vowel.new(word: "krk")}.to raise_error(constraint_error)
|
|
159
|
+
expect{ my_instance.instance_variable_set("@x", nil) }.to raise_error(FrozenError)
|
|
394
160
|
```
|
|
395
161
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
Then we can also use instance methods to implement our `Positive`
|
|
400
|
-
```ruby
|
|
401
|
-
positive_by_instance_method = positive.with_constraint(value: Fixnum.instance_method(:positive?))
|
|
402
|
-
|
|
403
|
-
expect(positive_by_instance_method.new(value: 1).value).to eq(1)
|
|
404
|
-
expect{positive_by_instance_method.new(value: 0)}.to raise_error(constraint_error)
|
|
405
|
-
```
|
|
162
|
+
#### Context: Immutable → Cloning
|
|
406
163
|
|
|
407
|
-
|
|
164
|
+
Given
|
|
408
165
|
```ruby
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
expect(positive_by_method.new(value: 1).value).to eq(1)
|
|
412
|
-
expect{positive_by_method.new(value: 0)}.to raise_error(constraint_error)
|
|
166
|
+
let(:other_instance) { my_instance.merge(email: "robert@mail.provider") }
|
|
413
167
|
```
|
|
414
|
-
|
|
415
|
-
#### Context: Global Constraints aka __Validations__
|
|
416
|
-
|
|
417
|
-
So far we have only speculated about constraints concerning one attribute, however sometimes we want
|
|
418
|
-
to have arbitrary constraints which can only be calculated by access to more attributes
|
|
419
|
-
|
|
420
|
-
Given a `Point` DataClass
|
|
168
|
+
Then we have a new instance with the old instance unchanged
|
|
421
169
|
```ruby
|
|
422
|
-
|
|
423
|
-
|
|
170
|
+
expect(other_instance.to_h).to eq(name: "robert", email: "robert@mail.provider")
|
|
171
|
+
expect(my_instance.to_h).to eq(name: "robert", email: nil)
|
|
424
172
|
```
|
|
425
|
-
|
|
426
|
-
Then we will get a `ValidationError` if we construct a point left of the main diagonal
|
|
173
|
+
And the new instance is frozen again
|
|
427
174
|
```ruby
|
|
428
|
-
expect
|
|
429
|
-
.to raise_error(validation_error)
|
|
175
|
+
expect(other_instance).to be_frozen
|
|
430
176
|
```
|
|
431
177
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
expect{ DataClass(x: 0, y: 0).validate{ |inst| inst.x > inst.y } }
|
|
435
|
-
.to_not raise_error
|
|
436
|
-
```
|
|
178
|
+
For speculations how to add all the other features to the _Factory Function_ syntax please
|
|
179
|
+
look [here](speculations/FACTORY_FUNCTION.md)
|
|
437
180
|
|
|
438
|
-
And we can name validations to get better error messages
|
|
439
|
-
```ruby
|
|
440
|
-
better_point = DataClass(:x, :y).validate(:too_left){ |point| point.x > point.y }
|
|
441
|
-
ok_point = better_point.new(x: 1, y: 0)
|
|
442
|
-
expect{ ok_point.merge(y: 1) }
|
|
443
|
-
.to raise_error(validation_error, "too_left")
|
|
444
|
-
```
|
|
445
181
|
|
|
446
|
-
And remark how bad unnamed validation errors might be
|
|
447
|
-
```ruby
|
|
448
|
-
error_message_rgx = %r{
|
|
449
|
-
\#<Proc:0x[0-9a-f]+ \s .* spec/speculations/README_spec\.rb: \d+ > \z
|
|
450
|
-
}x
|
|
451
|
-
expect{ point.new(x: 0, y: 1) }
|
|
452
|
-
.to raise_error(validation_error, error_message_rgx)
|
|
453
|
-
```
|
|
454
182
|
|
|
455
183
|
## Context: `Pair` and `Triple`
|
|
456
184
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kernel
|
|
4
|
+
def DataClass(*args, **kwds, &blk)
|
|
5
|
+
proxy = Lab42::DataClass::Proxy.new(*args, **kwds, &blk)
|
|
6
|
+
proxy.define_class!
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def Pair(first, second)
|
|
10
|
+
Lab42::Pair.new(first, second)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def Triple(first, second, third)
|
|
14
|
+
Lab42::Triple.new(first, second, third)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
@@ -23,6 +23,16 @@ module Lab42
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
def define_constraints(constraints)
|
|
27
|
+
errors = constraints.map(&define_constraint).compact
|
|
28
|
+
unless errors.empty?
|
|
29
|
+
raise ArgumentError,
|
|
30
|
+
"constraints cannot be defined for undefined attributes #{errors.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
check_constraints_against_defaults(constraints)
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
private
|
|
27
37
|
|
|
28
38
|
def _check_constraint_against_default
|
|
@@ -34,7 +44,7 @@ module Lab42
|
|
|
34
44
|
end
|
|
35
45
|
|
|
36
46
|
def _check_constraint_against_default_value(attr, value, constraint)
|
|
37
|
-
unless constraint.(value)
|
|
47
|
+
unless Maker.make_constraint(constraint).(value)
|
|
38
48
|
"default value #{value.inspect} is not allowed for attribute #{attr.inspect}"
|
|
39
49
|
end
|
|
40
50
|
rescue StandardError => e
|
|
@@ -68,13 +78,7 @@ module Lab42
|
|
|
68
78
|
proxy = self
|
|
69
79
|
->(*) do
|
|
70
80
|
define_method :with_constraint do |**constraints|
|
|
71
|
-
|
|
72
|
-
unless errors.empty?
|
|
73
|
-
raise ArgumentError,
|
|
74
|
-
"constraints cannot be defined for undefined attributes #{errors.inspect}"
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
proxy.check_constraints_against_defaults(constraints)
|
|
81
|
+
proxy.define_constraints(constraints)
|
|
78
82
|
self
|
|
79
83
|
end
|
|
80
84
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lab42
|
|
4
|
+
module DataClass
|
|
5
|
+
class Proxy
|
|
6
|
+
module Derived
|
|
7
|
+
private
|
|
8
|
+
def _define_derived
|
|
9
|
+
proxy = self
|
|
10
|
+
->(*) do
|
|
11
|
+
define_method :derive do |att_name, &blk|
|
|
12
|
+
proxy.define_derived_attribute(att_name, &blk)
|
|
13
|
+
self
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lab42
|
|
4
|
+
module DataClass
|
|
5
|
+
class Proxy
|
|
6
|
+
module Memos
|
|
7
|
+
def all_attributes
|
|
8
|
+
@__all_attributes__ ||= members&.union(Set.new(derived_attributes.keys))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def constraints
|
|
12
|
+
@__constraints__ ||= Hash.new { |h, k| h[k] = [] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def defaults
|
|
16
|
+
@__defaults__ ||= {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def derived_attributes
|
|
20
|
+
@__derived_attributes__ ||= {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def members
|
|
24
|
+
@__members__ ||= unless (positionals + defaults.keys).empty?
|
|
25
|
+
Set.new(positionals + defaults.keys)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def positionals
|
|
30
|
+
@__positionals__ ||= []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validations
|
|
34
|
+
@__validations__ ||= []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def _missing_initializers
|
|
40
|
+
@___missing_initializers__ ||=
|
|
41
|
+
positionals - actual_params.keys
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def _illegal_initializers
|
|
45
|
+
@___illegal_initializers__ ||=
|
|
46
|
+
actual_params.keys - positionals - defaults.keys
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
@@ -2,58 +2,89 @@
|
|
|
2
2
|
|
|
3
3
|
require 'set'
|
|
4
4
|
require_relative 'proxy/constraints'
|
|
5
|
+
require_relative 'proxy/derived'
|
|
6
|
+
require_relative 'proxy/memos'
|
|
5
7
|
require_relative 'proxy/validations'
|
|
6
8
|
require_relative 'proxy/mixin'
|
|
7
9
|
module Lab42
|
|
8
10
|
module DataClass
|
|
9
11
|
class Proxy
|
|
10
|
-
include Constraints, Validations
|
|
12
|
+
include Constraints, Derived, Memos, Validations
|
|
11
13
|
|
|
12
|
-
attr_reader :actual_params, :
|
|
14
|
+
attr_reader :actual_params, :block, :klass, :klass_defined
|
|
15
|
+
|
|
16
|
+
def self.from_parent(parent, klass)
|
|
17
|
+
new(klass).tap do |proxy|
|
|
18
|
+
proxy.positionals.push(*parent.positionals)
|
|
19
|
+
proxy.defaults.update(parent.defaults)
|
|
20
|
+
proxy.constraints.update(parent.constraints)
|
|
21
|
+
proxy.validations.push(*parent.validations)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def access(data_class_instance, key)
|
|
26
|
+
if all_attributes.member?(key)
|
|
27
|
+
data_class_instance.send(key)
|
|
28
|
+
else
|
|
29
|
+
raise KeyError, "#{key} is not an attribute of #{data_class_instance}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
13
32
|
|
|
14
33
|
def check!(**params)
|
|
15
34
|
@actual_params = params
|
|
16
|
-
@all_params = defaults.merge(params)
|
|
17
35
|
raise ArgumentError, "missing initializers for #{_missing_initializers}" unless _missing_initializers.empty?
|
|
18
36
|
raise ArgumentError, "illegal initializers #{_illegal_initializers}" unless _illegal_initializers.empty?
|
|
19
37
|
|
|
20
|
-
_check_constraints!(
|
|
38
|
+
_check_constraints!(defaults.merge(params))
|
|
21
39
|
end
|
|
22
40
|
|
|
23
41
|
def define_class!
|
|
42
|
+
return if @klass_defined
|
|
43
|
+
|
|
44
|
+
@klass_defined = true
|
|
24
45
|
klass.module_eval(&_define_attr_reader)
|
|
25
|
-
klass.module_eval(&_define_initializer)
|
|
46
|
+
klass.module_eval(&_define_initializer) if Class === klass
|
|
26
47
|
_define_methods
|
|
27
48
|
klass.include(Mixin)
|
|
28
49
|
klass.module_eval(&block) if block
|
|
29
50
|
klass
|
|
30
51
|
end
|
|
31
52
|
|
|
53
|
+
def define_derived_attribute(name, &blk)
|
|
54
|
+
positionals.delete(name)
|
|
55
|
+
defaults.delete(name)
|
|
56
|
+
derived_attributes.update(name => true) do |_key, _old,|
|
|
57
|
+
raise DuplicateDefinitionError, "Redefinition of derived attribute #{name}"
|
|
58
|
+
end
|
|
59
|
+
klass.module_eval(&_define_derived_attribute(name, &blk))
|
|
60
|
+
end
|
|
61
|
+
|
|
32
62
|
def init(data_class, **params)
|
|
33
63
|
_init(data_class, defaults.merge(params))
|
|
34
64
|
end
|
|
35
65
|
|
|
36
66
|
def to_hash(data_class_instance)
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
.to_h
|
|
67
|
+
all_attributes
|
|
68
|
+
.inject({}) { |result, (k, _)| result.merge(k => data_class_instance[k]) }
|
|
40
69
|
end
|
|
41
70
|
|
|
42
|
-
def
|
|
43
|
-
|
|
71
|
+
def update!(with_positionals, with_keywords)
|
|
72
|
+
positionals.push(*with_positionals)
|
|
73
|
+
defaults.update(with_keywords)
|
|
44
74
|
end
|
|
45
75
|
|
|
46
76
|
private
|
|
47
77
|
def initialize(*args, **kwds, &blk)
|
|
48
|
-
@klass =
|
|
49
|
-
|
|
50
|
-
|
|
78
|
+
@klass = if Module === args.first
|
|
79
|
+
args.shift
|
|
80
|
+
else
|
|
81
|
+
Class.new
|
|
82
|
+
end
|
|
51
83
|
|
|
84
|
+
@klass_defined = false
|
|
52
85
|
@block = blk
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
# TODO: Check for all symbols and no duplicates ⇒ v0.5.1
|
|
56
|
-
@positionals = args
|
|
86
|
+
defaults.update(kwds)
|
|
87
|
+
positionals.push(*args)
|
|
57
88
|
end
|
|
58
89
|
|
|
59
90
|
def _define_attr_reader
|
|
@@ -63,6 +94,12 @@ module Lab42
|
|
|
63
94
|
end
|
|
64
95
|
end
|
|
65
96
|
|
|
97
|
+
def _define_derived_attribute(name, &blk)
|
|
98
|
+
->(*) do
|
|
99
|
+
define_method(name) { blk.call(self) }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
66
103
|
def _define_freezing_constructor
|
|
67
104
|
->(*) do
|
|
68
105
|
define_method :new do |*a, **p, &b|
|
|
@@ -92,8 +129,8 @@ module Lab42
|
|
|
92
129
|
end
|
|
93
130
|
|
|
94
131
|
def _define_methods
|
|
95
|
-
|
|
96
|
-
|
|
132
|
+
_define_singleton_methods(klass.singleton_class)
|
|
133
|
+
klass.module_eval(&_define_access)
|
|
97
134
|
klass.module_eval(&_define_to_h)
|
|
98
135
|
klass.module_eval(&_define_merge)
|
|
99
136
|
end
|
|
@@ -102,9 +139,19 @@ module Lab42
|
|
|
102
139
|
singleton.module_eval(&_define_freezing_constructor)
|
|
103
140
|
singleton.module_eval(&_define_to_proc)
|
|
104
141
|
singleton.module_eval(&_define_with_constraint)
|
|
142
|
+
singleton.module_eval(&_define_derived)
|
|
105
143
|
singleton.module_eval(&_define_with_validations)
|
|
106
144
|
end
|
|
107
145
|
|
|
146
|
+
def _define_access
|
|
147
|
+
proxy = self
|
|
148
|
+
->(*) do
|
|
149
|
+
define_method :[] do |key|
|
|
150
|
+
proxy.access(self, key)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
108
155
|
def _define_to_h
|
|
109
156
|
proxy = self
|
|
110
157
|
->(*) do
|
|
@@ -128,16 +175,6 @@ module Lab42
|
|
|
128
175
|
data_class_instance.instance_variable_set("@#{key}", value)
|
|
129
176
|
end
|
|
130
177
|
end
|
|
131
|
-
|
|
132
|
-
def _missing_initializers
|
|
133
|
-
@___missing_initializers__ ||=
|
|
134
|
-
positionals - actual_params.keys
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def _illegal_initializers
|
|
138
|
-
@___illegal_initializers__ ||=
|
|
139
|
-
actual_params.keys - positionals - defaults.keys
|
|
140
|
-
end
|
|
141
178
|
end
|
|
142
179
|
end
|
|
143
180
|
end
|
data/lib/lab42/data_class.rb
CHANGED
|
@@ -1,24 +1,49 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative './data_class/constraint_error'
|
|
4
|
+
require_relative './data_class/duplicate_definition_error'
|
|
5
|
+
require_relative './data_class/kernel'
|
|
4
6
|
require_relative './data_class/validation_error'
|
|
5
7
|
require_relative './data_class/proxy'
|
|
6
8
|
require_relative './pair'
|
|
7
9
|
require_relative './triple'
|
|
8
10
|
|
|
9
|
-
module
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
module Lab42
|
|
12
|
+
module DataClass
|
|
13
|
+
def self.extended(extendee)
|
|
14
|
+
base_proxy =
|
|
15
|
+
extendee
|
|
16
|
+
.ancestors
|
|
17
|
+
.grep(self)
|
|
18
|
+
.drop(1)
|
|
19
|
+
.first
|
|
20
|
+
&.__data_class_proxy__
|
|
14
21
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
proxy = base_proxy ? Proxy.from_parent(base_proxy, extendee) : Proxy.new(extendee)
|
|
23
|
+
|
|
24
|
+
extendee.module_eval do
|
|
25
|
+
define_singleton_method(:__data_class_proxy__){ proxy }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def attributes(*args, **kwds)
|
|
30
|
+
__data_class_proxy__.tap do |proxy|
|
|
31
|
+
proxy.update!(args, kwds)
|
|
32
|
+
proxy.define_class!
|
|
33
|
+
end
|
|
34
|
+
end
|
|
18
35
|
|
|
19
|
-
|
|
20
|
-
|
|
36
|
+
def derive(att_name, &blk)
|
|
37
|
+
__data_class_proxy__.define_derived_attribute(att_name, &blk)
|
|
38
|
+
__data_class_proxy__.define_class!
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def constraint(member, constraint = nil, &block)
|
|
42
|
+
raise ArgumentError, "must not provide constraint (2nd argument) and a block" if block && constraint
|
|
43
|
+
|
|
44
|
+
__data_class_proxy__.define_constraints(member => constraint || block)
|
|
45
|
+
__data_class_proxy__.define_class!
|
|
46
|
+
end
|
|
21
47
|
end
|
|
22
48
|
end
|
|
23
|
-
|
|
24
49
|
# SPDX-License-Identifier: Apache-2.0
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lab42_data_class
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Robert Dober
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2022-02-
|
|
11
|
+
date: 2022-02-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |
|
|
14
14
|
An Immutable DataClass for Ruby
|
|
@@ -25,9 +25,13 @@ files:
|
|
|
25
25
|
- README.md
|
|
26
26
|
- lib/lab42/data_class.rb
|
|
27
27
|
- lib/lab42/data_class/constraint_error.rb
|
|
28
|
+
- lib/lab42/data_class/duplicate_definition_error.rb
|
|
29
|
+
- lib/lab42/data_class/kernel.rb
|
|
28
30
|
- lib/lab42/data_class/proxy.rb
|
|
29
31
|
- lib/lab42/data_class/proxy/constraints.rb
|
|
30
32
|
- lib/lab42/data_class/proxy/constraints/maker.rb
|
|
33
|
+
- lib/lab42/data_class/proxy/derived.rb
|
|
34
|
+
- lib/lab42/data_class/proxy/memos.rb
|
|
31
35
|
- lib/lab42/data_class/proxy/mixin.rb
|
|
32
36
|
- lib/lab42/data_class/proxy/validations.rb
|
|
33
37
|
- lib/lab42/data_class/validation_error.rb
|