lab42_data_class 0.6.0 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
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