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