lab42_data_class 0.6.0 → 0.7.2

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: 746ce73ef4464de258f683f0d8366f15799cb8e3ea34a062cc6f4deb34f0a60d
4
+ data.tar.gz: 96c679474b72b872f4885ab2c98f7ddfd5f914268b3a9da5ba59f7ae026e5eed
5
5
  SHA512:
6
- metadata.gz: 5e14f2656f03703aa867dce065bf9e4770cf043fd97d035cab9a6b869d9fb6e9749c2de3eef0b948e02c7ade07f85f75423c4b2412ed154574a6672e28411d9f
7
- data.tar.gz: f831c9c8094f7224de088f6f03847791bcc91a33db6e565fc1ef3f45d5f3f5e29d18b05dfbcdbc7dcc422755d0ab87dc73f0c50a9de45dcd3df0916d87fb1c16
6
+ metadata.gz: 184dc66df2185e0869a5cbce9811c8bedf685123037fadf8d17c30bfd762884b9f0d0756bc0d43787dce2586c8094bb2a798aba9b54087198b59a1a0d95023c0
7
+ data.tar.gz: 859c555137b976c4885157c5b39d42393c528adf94abb3dd28b9cbd3e38625d3bccac9b5cf5e317cdf3dddb08494fa989dd11523902f79402ed915822d616a64
data/README.md CHANGED
@@ -8,490 +8,174 @@
8
8
 
9
9
  # Lab42::DataClass
10
10
 
11
-
12
11
  An Immutable DataClass for Ruby
13
12
 
14
- Exposes a class factory function `Kernel::DataClass` and a class
15
- modifer `Module#dataclass`, also creates two _tuple_ classes, `Pair` and
16
- `Triple`
17
-
18
- ## Usage
19
-
20
- ```sh
21
- gem install lab42_data_class
22
- ```
23
-
24
- With bundler
25
-
26
- ```ruby
27
- gem 'lab42_data_class'
28
- ```
29
-
30
- In your code
31
-
32
- ```ruby
33
- require 'lab42/data_class'
34
- ```
35
-
36
-
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
- ```
13
+ Exposes a class factory function `Kernel::DataClass` and a module `Lab42::DataClass` which can
14
+ extend classes to become _Data Classes_.
191
15
 
192
- ### Context: Pattern Matching
16
+ Also exposes two _tuple_ classes, `Pair` and `Triple`
193
17
 
194
- A `DataClass` object behaves like the result of it's `to_h` in pattern matching
18
+ ## Synopsis
195
19
 
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
- ```
20
+ Having immutable Objects has many well known advantages that I will not ponder upon in detail here.
202
21
 
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
- ```
22
+ One advantage which is of particular interest though is that, as every, _modification_ is in fact the
23
+ creation of a new object **strong contraints** on the data can **easily** be maintained, and this
24
+ library makes that available to the user.
217
25
 
218
- And in `in` expressions
219
- ```ruby
220
- evens => {values: [_, second, *]}
221
- expect(second).to eq(4)
222
- ```
223
-
224
- #### Context: In Case Statements
26
+ Therefore we can summarise the features (or not so features, that is for you to decide and you to chose to use or not):
225
27
 
226
- Given a nice little dataclass `Box`
227
- ```ruby
228
- let(:box) { DataClass content: nil }
229
- ```
28
+ - Immutable with an Interface à la `OpenStruct`
29
+ - Attributes are predefined and can have **default values**
30
+ - Construction with _keyword arguments_, **exclusively**
31
+ - Conversion to `Hash` instances (if you must)
32
+ - Pattern matching exactly like `Hash` instances
33
+ - Possibility to impose **strong constraints** on attributes
34
+ - Predefined constraints and concise syntax for constraints
35
+ - Possibility to impose **arbitrary validation** (constraints on the whole object)
36
+ - Declaration of **dependent attributes** which are memoized (thank you _Immutability_)
37
+ - Inheritance with **mixin of other dataclasses** (multiple if you must)
230
38
 
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
- ```
39
+ ## Usage
241
40
 
242
- And all the associated methods
243
- ```ruby
244
- expect(box.new).to be_a(box)
245
- expect(box === box.new).to be_truthy
41
+ ```sh
42
+ gem install lab42_data_class
246
43
  ```
247
44
 
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
45
+ With bundler
251
46
 
252
- Given two different `DataClass` objects
253
47
  ```ruby
254
- let(:class1) { DataClass :value }
255
- let(:class2) { DataClass :value }
48
+ gem 'lab42_data_class'
256
49
  ```
257
50
 
258
- And a list of instances
259
- ```ruby
260
- let(:list) {[class1.new(value: 1), class2.new(value: 2), class1.new(value: 3)]}
261
- ```
51
+ In your code
262
52
 
263
- Then we can filter
264
53
  ```ruby
265
- expect(list.filter(&class2)).to eq([class2.new(value: 2)])
54
+ require 'lab42/data_class'
266
55
  ```
267
56
 
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
57
+ ## Speculations (literate specs)
272
58
 
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
- ```
59
+ The following specs are executed with the [speculate about](https://github.com/RobertDober/speculate_about) gem.
283
60
 
284
- Then we can pass it as keyword arguments
61
+ Given that we have imported the `Lab42` namespace
285
62
  ```ruby
286
- expect(extract_value(**my_class.new)).to eq([1, base: 2])
63
+ DataClass = Lab42::DataClass
287
64
  ```
288
65
 
289
- ### Context: Constraints
66
+ ## Context: Data Classes
290
67
 
291
- Values of attributes of a `DataClass` can have constraints
68
+ ### Basic Use Case
292
69
 
293
- Given a `DataClass` with constraints
70
+ Given a simple Data Class
294
71
  ```ruby
295
- let :switch do
296
- DataClass(on: false).with_constraint(on: -> { [false, true].member? _1 })
72
+ class SimpleDataClass
73
+ extend DataClass
74
+ attributes :a, :b
297
75
  end
298
76
  ```
299
77
 
300
- Then boolean values are acceptable
78
+ And an instance of it
301
79
  ```ruby
302
- expect{ switch.new }.not_to raise_error
303
- expect(switch.new.merge(on: true).on).to eq(true)
80
+ let(:simple_instance) { SimpleDataClass.new(a: 1, b: 2) }
304
81
  ```
305
82
 
306
- But we can neither construct or merge with non boolean values
83
+ Then we access the fields
307
84
  ```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")
85
+ expect(simple_instance.a).to eq(1)
86
+ expect(simple_instance.b).to eq(2)
312
87
  ```
313
88
 
314
- And therefore defaultless attributes cannot have a constraint that is violated by a nil value
89
+ And we convert to a hash
315
90
  ```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}/)
91
+ expect(simple_instance.to_h).to eq(a: 1, b: 2)
322
92
  ```
323
93
 
324
- And defining constraints for undefined attributes is not the best of ideas
94
+ And we can derive new instances
325
95
  ```ruby
326
- expect { DataClass(a: 1).with_constraint(b: -> {true}) }
327
- .to raise_error(ArgumentError, "constraints cannot be defined for undefined attributes [:b]")
96
+ new_instance = simple_instance.merge(b: 3)
97
+ expect(new_instance.to_h).to eq(a: 1, b: 3)
98
+ expect(simple_instance.to_h).to eq(a: 1, b: 2)
328
99
  ```
329
100
 
101
+ For detailed speculations please see [here](speculations/DATA_CLASSES.md)
330
102
 
331
- #### Context: Convenience Constraints
103
+ ## Context: `DataClass` function
332
104
 
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:
105
+ As seen in the speculations above it seems appropriate to declare a `Class` and
106
+ extend it as we will add quite some code for constraints, derived attributes and validations.
335
107
 
336
- Given a shortcut for our `ConstraintError`
337
- ```ruby
338
- let(:constraint_error) { Lab42::DataClass::ConstraintError }
339
- let(:positive) { DataClass(:value) }
340
- ```
108
+ However a more concise _Factory Function_ might still be very useful in some use cases...
341
109
 
342
- ##### Symbols
110
+ Enter `Kernel::DataClass` **The Function**
343
111
 
344
- ... are sent to the value of the attribute, this is not very surprising of course ;)
112
+ ### Context: Just Attributes
345
113
 
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
- ```
114
+ If there are no _Constraints_, _Derived Attributes_, _Validation_ or _Inheritance_ this concise syntax
115
+ might easily be preferred by many:
353
116
 
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`
117
+ Given some example instances like these
358
118
  ```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)
119
+ let(:my_data_class) { DataClass(:name, email: nil) }
120
+ let(:my_instance) { my_data_class.new(name: "robert") }
363
121
  ```
364
122
 
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`
123
+ Then we can access its fields
370
124
  ```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)
125
+ expect(my_instance.name).to eq("robert")
126
+ expect(my_instance[:email]).to be_nil
375
127
  ```
376
128
 
377
- And also with a `Range`
129
+ But we cannot access undefined fields
378
130
  ```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)
131
+ expect{ my_instance.undefined }.to raise_error(NoMethodError)
383
132
  ```
384
133
 
385
- ##### Regexen
386
-
387
- This seems quite obvious, and of course it works
388
-
389
- Then we can also have a regex based constraint
134
+ And this is even true for the `[]` syntax
390
135
  ```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)
136
+ expect{ my_instance[:undefined] }.to raise_error(KeyError)
395
137
  ```
396
138
 
397
- ##### Other callable objects as constraints
398
-
399
-
400
- Then we can also use instance methods to implement our `Positive`
139
+ And we need to provide values to fields without defaults
401
140
  ```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)
141
+ expect{ my_data_class.new(email: "some@mail.org") }
142
+ .to raise_error(ArgumentError, "missing initializers for [:name]")
406
143
  ```
407
-
408
- Or we can use methods to implement it
144
+ And we can extract the values
409
145
  ```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)
146
+ expect(my_instance.to_h).to eq(name: "robert", email: nil)
414
147
  ```
415
148
 
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
- ```
149
+ #### Context: Immutable self
426
150
 
427
- Then we will get a `ValidationError` if we construct a point left of the main diagonal
151
+ Then `my_instance` is frozen:
428
152
  ```ruby
429
- expect{ point.new(x: 0, y: 1) }
430
- .to raise_error(validation_error)
153
+ expect(my_instance).to be_frozen
431
154
  ```
432
-
433
- But as validation might need more than the default values we will not execute them at compile time
155
+ And we cannot even mute `my_instance` by means of metaprogramming
434
156
  ```ruby
435
- expect{ DataClass(x: 0, y: 0).validate{ |inst| inst.x > inst.y } }
436
- .to_not raise_error
157
+ expect{ my_instance.instance_variable_set("@x", nil) }.to raise_error(FrozenError)
437
158
  ```
438
159
 
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
- ```
160
+ #### Context: Immutable Cloning
446
161
 
447
- And remark how bad unnamed validation errors might be
162
+ Given
448
163
  ```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)
164
+ let(:other_instance) { my_instance.merge(email: "robert@mail.provider") }
454
165
  ```
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`
166
+ Then we have a new instance with the old instance unchanged
462
167
  ```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) }
168
+ expect(other_instance.to_h).to eq(name: "robert", email: "robert@mail.provider")
169
+ expect(my_instance.to_h).to eq(name: "robert", email: nil)
475
170
  ```
476
-
477
- Then we can observe that instances of such a class
171
+ And the new instance is frozen again
478
172
  ```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
173
+ expect(other_instance).to be_frozen
482
174
  ```
483
175
 
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
- ```
176
+ For speculations how to add all the other features to the _Factory Function_ syntax please
177
+ look [here](speculations/FACTORY_FUNCTION.md)
489
178
 
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
179
 
496
180
 
497
181
  ## Context: `Pair` and `Triple`
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constraint"
4
+ require_relative "constraints/kernel"
5
+
6
+ module Lab42
7
+ module DataClass
8
+ module BuiltinConstraints
9
+ extend self
10
+ end
11
+ end
12
+ end
13
+
14
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lab42/data_class"
4
+ module Lab42
5
+ module DataClass
6
+ class Constraint
7
+ attr_reader :name, :function
8
+
9
+ def call(value)
10
+ function.(value)
11
+ end
12
+
13
+ def to_s
14
+ "Constraint<#{name}>"
15
+ end
16
+
17
+ private
18
+ def initialize(name:, function:)
19
+ raise ArgumentError, "name not a String, but #{name}" unless String === name
20
+ unless function.respond_to?(:arity) && function.arity == 1
21
+ raise ArgumentError, "function not a callable with arity 1 #{function}"
22
+ end
23
+
24
+ @name = name
25
+ @function = function
26
+ end
27
+ end
28
+ end
29
+ end
30
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kernel
4
+ Constraint = Lab42::DataClass::Constraint
5
+ Maker = Lab42::DataClass::Proxy::Constraints::Maker # TODO: Move Maker to Lab42::DataClass:ConstraintMaker
6
+ Anything = Constraint.new(name: "Anything", function: ->(_) { true })
7
+ Boolean = Constraint.new(name: "Boolean", function: -> { [false, true].member?(_1) })
8
+
9
+ def All?(constraint = nil, &blk)
10
+ constraint = Maker.make_constraint(constraint, &blk)
11
+ f = -> do
12
+ _1.all?(&constraint)
13
+ end
14
+ Constraint.new(name: "All?(#{constraint})", function: f)
15
+ end
16
+
17
+ def Any?(constraint = nil, &blk)
18
+ constraint = Maker.make_constraint(constraint, &blk)
19
+ f = -> do
20
+ _1.any?(&constraint)
21
+ end
22
+ Constraint.new(name: "Any?(#{constraint})", function: f)
23
+ end
24
+
25
+ def Choice(*constraints)
26
+ constraints = constraints.map{ Maker.make_constraint _1 }
27
+ f = ->(value) do
28
+ constraints.any?{ _1.(value) }
29
+ end
30
+ Constraint.new(name: "Choice(#{constraints.join(', ')})", function: f)
31
+ end
32
+
33
+ def Contains(str)
34
+ f = -> { _1.include?(str) rescue false }
35
+ Constraint.new(name: "Contains(#{str})", function: f)
36
+ end
37
+
38
+ def EndsWith(str)
39
+ f = -> { _1.end_with?(str) rescue false }
40
+ Constraint.new(name: "EndsWith(#{str})", function: f)
41
+ end
42
+
43
+ def Lambda(arity)
44
+ f = -> do
45
+ _1.arity == arity rescue false
46
+ end
47
+ Constraint.new(name: "Lambda(#{arity})", function: f)
48
+ end
49
+
50
+ def NilOr(constraint = nil, &blk)
51
+ constraint = Maker.make_constraint(constraint, &blk)
52
+ f = -> { _1.nil? || constraint.(_1) }
53
+ Constraint.new(name: "NilOr(#{constraint})", function: f)
54
+ end
55
+
56
+ def Not(constraint = nil, &blk)
57
+ constraint = Maker.make_constraint(constraint, &blk)
58
+ f = -> { !constraint.(_1) }
59
+ Constraint.new(name: "Not(#{constraint})", function: f)
60
+ end
61
+
62
+ def PairOf(fst, snd)
63
+ fst_constraint = Maker.make_constraint(fst)
64
+ snd_constraint = Maker.make_constraint(snd)
65
+ f = -> do
66
+ Lab42::Pair === _1 && fst_constraint.(_1.first) && snd_constraint.(_1.second)
67
+ end
68
+ Constraint.new(name: "PairOf(#{fst_constraint}, #{snd_constraint})", function: f)
69
+ end
70
+
71
+ def StartsWith(str)
72
+ f = -> { _1.start_with?(str) rescue false }
73
+ Constraint.new(name: "StartsWith(#{str})", function: f)
74
+ end
75
+
76
+ def TripleOf(fst, snd, trd)
77
+ fst_constraint = Maker.make_constraint(fst)
78
+ snd_constraint = Maker.make_constraint(snd)
79
+ trd_constraint = Maker.make_constraint(trd)
80
+ f = -> do
81
+ Lab42::Triple === _1 && fst_constraint.(_1.first) && snd_constraint.(_1.second) && trd_constraint.(_1.third)
82
+ end
83
+ Constraint.new(name: "TripleOf(#{fst_constraint}, #{snd_constraint}, #{trd_constraint})", function: f)
84
+ end
85
+ end
86
+ # SPDX-License-Identifier: Apache-2.0
@@ -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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "lab42/data_class/constraint"
3
4
  module Lab42
4
5
  module DataClass
5
6
  class Proxy
@@ -7,9 +8,17 @@ module Lab42
7
8
  module Maker
8
9
  extend self
9
10
 
10
- def make_constraint(constraint)
11
+ def make_constraint(constraint, &blk)
12
+ raise ArgumentError, "must not pass a callable #{constraint} and a block" if constraint && blk
13
+
14
+ _make_constraint(constraint || blk)
15
+ end
16
+
17
+ private
18
+
19
+ def _make_constraint(constraint)
11
20
  case constraint
12
- when Proc, Method
21
+ when Lab42::DataClass::Constraint, Proc, Method
13
22
  constraint
14
23
  when Symbol
15
24
  -> { _1.send(constraint) }
@@ -19,13 +28,13 @@ module Lab42
19
28
  -> { constraint.match?(_1) }
20
29
  when UnboundMethod
21
30
  -> { constraint.bind(_1).() }
31
+ when Module
32
+ -> { constraint === _1 }
22
33
  else
23
34
  _make_member_constraint(constraint)
24
35
  end
25
36
  end
26
37
 
27
- private
28
-
29
38
  def _make_member_constraint(constraint)
30
39
  if constraint.respond_to?(:member?)
31
40
  -> { constraint.member?(_1) }
@@ -14,7 +14,7 @@ module Lab42
14
14
 
15
15
  def define_constraint
16
16
  ->((attr, constraint)) do
17
- if members.member?(attr)
17
+ if members!.member?(attr)
18
18
  constraints[attr] << Maker.make_constraint(constraint)
19
19
  nil
20
20
  else
@@ -26,7 +26,7 @@ module Lab42
26
26
  def define_constraints(constraints)
27
27
  errors = constraints.map(&define_constraint).compact
28
28
  unless errors.empty?
29
- raise ArgumentError,
29
+ raise UndefinedAttributeError,
30
30
  "constraints cannot be defined for undefined attributes #{errors.inspect}"
31
31
  end
32
32
 
@@ -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,12 +16,20 @@ 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)
18
26
  end
19
27
  end
20
28
 
29
+ def members!
30
+ @__members__ = Set.new(positionals + defaults.keys)
31
+ end
32
+
21
33
  def positionals
22
34
  @__positionals__ ||= []
23
35
  end
@@ -2,41 +2,73 @@
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
14
15
 
15
- def check!(**params)
16
- @actual_params = params
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
32
+
33
+ def check!(params, merge_with = defaults)
17
34
  raise ArgumentError, "missing initializers for #{_missing_initializers}" unless _missing_initializers.empty?
18
35
  raise ArgumentError, "illegal initializers #{_illegal_initializers}" unless _illegal_initializers.empty?
19
36
 
20
- _check_constraints!(defaults.merge(params))
37
+ _check_constraints!(merge_with.merge(params))
21
38
  end
22
39
 
23
40
  def define_class!
41
+ return if @klass_defined
42
+
43
+ @klass_defined = true
24
44
  klass.module_eval(&_define_attr_reader)
25
- klass.module_eval(&_define_initializer)
45
+ klass.module_eval(&_define_initializer) if Class === klass
26
46
  _define_methods
27
47
  klass.include(Mixin)
28
48
  klass.module_eval(&block) if block
29
49
  klass
30
50
  end
31
51
 
52
+ def define_derived_attribute(name, &blk)
53
+ positionals.delete(name)
54
+ defaults.delete(name)
55
+ derived_attributes.update(name => true) do |_key, _old,|
56
+ raise DuplicateDefinitionError, "Redefinition of derived attribute #{name.inspect}"
57
+ end
58
+ klass.module_eval(&_define_derived_attribute(name, &blk))
59
+ end
60
+
32
61
  def init(data_class, **params)
33
62
  _init(data_class, defaults.merge(params))
34
63
  end
35
64
 
65
+ def set_actual_params(params)
66
+ @actual_params = params
67
+ end
68
+
36
69
  def to_hash(data_class_instance)
37
- members
38
- .map { [_1, data_class_instance.instance_variable_get("@#{_1}")] }
39
- .to_h
70
+ all_attributes
71
+ .inject({}) { |result, (k, _)| result.merge(k => data_class_instance[k]) }
40
72
  end
41
73
 
42
74
  def update!(with_positionals, with_keywords)
@@ -46,12 +78,13 @@ module Lab42
46
78
 
47
79
  private
48
80
  def initialize(*args, **kwds, &blk)
49
- @klass = if Class === args.first
81
+ @klass = if Module === args.first
50
82
  args.shift
51
83
  else
52
84
  Class.new
53
85
  end
54
86
 
87
+ @klass_defined = false
55
88
  @block = blk
56
89
  defaults.update(kwds)
57
90
  positionals.push(*args)
@@ -64,11 +97,42 @@ module Lab42
64
97
  end
65
98
  end
66
99
 
100
+ def _define_derived_attribute(name, &blk)
101
+ ->(*) do
102
+ if instance_methods.include?(name)
103
+ begin
104
+ remove_method(name)
105
+ rescue StandardError
106
+ nil
107
+ end
108
+ end
109
+ define_method(name) { blk.call(self) }
110
+ end
111
+ end
112
+
67
113
  def _define_freezing_constructor
114
+ proxy = self
115
+ ->(*) do
116
+ define_method :new do |**params, &b|
117
+ allocate.tap do |o|
118
+ proxy.set_actual_params(params)
119
+ proxy.check!(params)
120
+ o.send(:initialize, **params, &b)
121
+ end.freeze
122
+ end
123
+ end
124
+ end
125
+
126
+ def _define_merging_constructor
127
+ proxy = self
68
128
  ->(*) do
69
- define_method :new do |*a, **p, &b|
70
- super(*a, **p, &b).freeze
129
+ define_method :_new_from_merge do |new_params, params|
130
+ allocate.tap do |o|
131
+ proxy.check!(new_params, {})
132
+ o.send(:initialize, **params)
133
+ end.freeze
71
134
  end
135
+ private :_new_from_merge
72
136
  end
73
137
  end
74
138
 
@@ -76,7 +140,6 @@ module Lab42
76
140
  proxy = self
77
141
  ->(*) do
78
142
  define_method :initialize do |**params|
79
- proxy.check!(**params)
80
143
  proxy.init(self, **params)
81
144
  proxy.validate!(self)
82
145
  end
@@ -87,25 +150,36 @@ module Lab42
87
150
  ->(*) do
88
151
  define_method :merge do |**params|
89
152
  values = to_h.merge(params)
90
- self.class.new(**values)
153
+ self.class.send(:_new_from_merge, params, values)
91
154
  end
92
155
  end
93
156
  end
94
157
 
95
158
  def _define_methods
96
- class << klass; self end
97
- .tap { |singleton| _define_singleton_methods(singleton) }
159
+ _define_singleton_methods(klass.singleton_class)
160
+ klass.module_eval(&_define_access)
98
161
  klass.module_eval(&_define_to_h)
99
162
  klass.module_eval(&_define_merge)
100
163
  end
101
164
 
102
165
  def _define_singleton_methods(singleton)
103
166
  singleton.module_eval(&_define_freezing_constructor)
167
+ singleton.module_eval(&_define_merging_constructor)
104
168
  singleton.module_eval(&_define_to_proc)
105
169
  singleton.module_eval(&_define_with_constraint)
170
+ singleton.module_eval(&_define_derived)
106
171
  singleton.module_eval(&_define_with_validations)
107
172
  end
108
173
 
174
+ def _define_access
175
+ proxy = self
176
+ ->(*) do
177
+ define_method :[] do |key|
178
+ proxy.access(self, key)
179
+ end
180
+ end
181
+ end
182
+
109
183
  def _define_to_h
110
184
  proxy = self
111
185
  ->(*) do
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab42
4
+ module DataClass
5
+ class UndefinedAttributeError < RuntimeError
6
+ end
7
+ end
8
+ end
9
+ # SPDX-License-Identifier: Apache-2.0
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Lab42
4
4
  module DataClass
5
- VERSION = "0.6.0"
5
+ VERSION = "0.7.2"
6
6
  end
7
7
  end
8
8
  # SPDX-License-Identifier: Apache-2.0
@@ -1,7 +1,9 @@
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'
6
+ require_relative './data_class/undefined_attribute_error'
5
7
  require_relative './data_class/validation_error'
6
8
  require_relative './data_class/proxy'
7
9
  require_relative './pair'
@@ -9,10 +11,18 @@ require_relative './triple'
9
11
 
10
12
  module Lab42
11
13
  module DataClass
12
- def self.extended(extender)
13
- proxy = Proxy.new(extender)
14
+ def self.extended(extendee)
15
+ base_proxy =
16
+ extendee
17
+ .ancestors
18
+ .grep(self)
19
+ .drop(1)
20
+ .first
21
+ &.__data_class_proxy__
14
22
 
15
- extender.module_eval do
23
+ proxy = base_proxy ? Proxy.from_parent(base_proxy, extendee) : Proxy.new(extendee)
24
+
25
+ extendee.module_eval do
16
26
  define_singleton_method(:__data_class_proxy__){ proxy }
17
27
  end
18
28
  end
@@ -24,10 +34,16 @@ module Lab42
24
34
  end
25
35
  end
26
36
 
37
+ def derive(att_name, &blk)
38
+ __data_class_proxy__.define_derived_attribute(att_name, &blk)
39
+ __data_class_proxy__.define_class!
40
+ end
41
+
27
42
  def constraint(member, constraint = nil, &block)
28
43
  raise ArgumentError, "must not provide constraint (2nd argument) and a block" if block && constraint
29
44
 
30
45
  __data_class_proxy__.define_constraints(member => constraint || block)
46
+ __data_class_proxy__.define_class!
31
47
  end
32
48
  end
33
49
  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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Dober
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-02-24 00:00:00.000000000 Z
11
+ date: 2022-03-03 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  An Immutable DataClass for Ruby
@@ -24,14 +24,20 @@ files:
24
24
  - LICENSE
25
25
  - README.md
26
26
  - lib/lab42/data_class.rb
27
+ - lib/lab42/data_class/builtin_constraints.rb
28
+ - lib/lab42/data_class/constraint.rb
27
29
  - lib/lab42/data_class/constraint_error.rb
30
+ - lib/lab42/data_class/constraints/kernel.rb
31
+ - lib/lab42/data_class/duplicate_definition_error.rb
28
32
  - lib/lab42/data_class/kernel.rb
29
33
  - lib/lab42/data_class/proxy.rb
30
34
  - lib/lab42/data_class/proxy/constraints.rb
31
35
  - lib/lab42/data_class/proxy/constraints/maker.rb
36
+ - lib/lab42/data_class/proxy/derived.rb
32
37
  - lib/lab42/data_class/proxy/memos.rb
33
38
  - lib/lab42/data_class/proxy/mixin.rb
34
39
  - lib/lab42/data_class/proxy/validations.rb
40
+ - lib/lab42/data_class/undefined_attribute_error.rb
35
41
  - lib/lab42/data_class/validation_error.rb
36
42
  - lib/lab42/data_class/version.rb
37
43
  - lib/lab42/eq_and_patterns.rb
@@ -41,7 +47,7 @@ homepage: https://github.com/robertdober/lab42_data_class
41
47
  licenses:
42
48
  - Apache-2.0
43
49
  metadata: {}
44
- post_install_message:
50
+ post_install_message:
45
51
  rdoc_options: []
46
52
  require_paths:
47
53
  - lib
@@ -57,7 +63,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
57
63
  version: '0'
58
64
  requirements: []
59
65
  rubygems_version: 3.3.3
60
- signing_key:
66
+ signing_key:
61
67
  specification_version: 4
62
68
  summary: Finally a dataclass in ruby
63
69
  test_files: []