lab42_data_class 0.6.0 → 0.7.2

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: 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: []