lab42_data_class 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +173 -1
- data/lib/lab42/data_class/constraint_error.rb +9 -0
- data/lib/lab42/data_class/proxy/constraints/maker.rb +39 -0
- data/lib/lab42/data_class/proxy/constraints.rb +86 -0
- data/lib/lab42/data_class/proxy/validations.rb +38 -0
- data/lib/lab42/data_class/proxy.rb +25 -4
- data/lib/lab42/data_class/validation_error.rb +9 -0
- data/lib/lab42/data_class/version.rb +1 -1
- data/lib/lab42/data_class.rb +2 -0
- metadata +12 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4d8b97ca60e61ad4b3ec85555649e5538ca35ef1018bcdf2a1cd9f86d4183718
|
4
|
+
data.tar.gz: 7076ca2be4a3a8e5428b2fa0fa14523c416d491d14a8ba254842405332215dba
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c74e746f2f71ac1cbd89a10566b5655e0cb4e2e9d6ff6031e147bb833b8af91b5e1251e89b0ed826401a1915e3654b8543d065138fc705491bf1c684d85d4b97
|
7
|
+
data.tar.gz: bf15dd5127834637d6c9e0e78cbaf4cb50e75afd03b3ba4d763e186ad65f00df9076892bd151bebad30d4e1d8d49b287225e00ef0ccf6eed3e7965ba1f47af62
|
data/README.md
CHANGED
@@ -8,7 +8,12 @@
|
|
8
8
|
|
9
9
|
# Lab42::DataClass
|
10
10
|
|
11
|
-
|
11
|
+
|
12
|
+
An Immutable DataClass for Ruby
|
13
|
+
|
14
|
+
Exposes a class factory function `Kernel::DataClass` and a class
|
15
|
+
modifer `Module#dataclass', also creates two _tuple_ classes, `Pair` and
|
16
|
+
`Triple`
|
12
17
|
|
13
18
|
## Usage
|
14
19
|
|
@@ -280,6 +285,173 @@ Then we can pass it as keyword arguments
|
|
280
285
|
expect(extract_value(**my_class.new)).to eq([1, base: 2])
|
281
286
|
```
|
282
287
|
|
288
|
+
### Context: Constraints
|
289
|
+
|
290
|
+
Values of attributes of a `DataClass` can have constraints
|
291
|
+
|
292
|
+
Given a `DataClass` with constraints
|
293
|
+
```ruby
|
294
|
+
let :switch do
|
295
|
+
DataClass(on: false).with_constraint(on: -> { [false, true].member? _1 })
|
296
|
+
end
|
297
|
+
```
|
298
|
+
|
299
|
+
Then boolean values are acceptable
|
300
|
+
```ruby
|
301
|
+
expect{ switch.new }.not_to raise_error
|
302
|
+
expect(switch.new.merge(on: true).on).to eq(true)
|
303
|
+
```
|
304
|
+
|
305
|
+
But we can neither construct or merge with non boolean values
|
306
|
+
```ruby
|
307
|
+
expect{ switch.new(on: nil) }
|
308
|
+
.to raise_error(Lab42::DataClass::ConstraintError, "value nil is not allowed for attribute :on")
|
309
|
+
expect{ switch.new.merge(on: 42) }
|
310
|
+
.to raise_error(Lab42::DataClass::ConstraintError, "value 42 is not allowed for attribute :on")
|
311
|
+
```
|
312
|
+
|
313
|
+
And therefore defaultless attributes cannot have a constraint that is violated by a nil value
|
314
|
+
```ruby
|
315
|
+
error_head = "constraint error during validation of default value of attribute :value"
|
316
|
+
error_body = " undefined method `>' for nil:NilClass"
|
317
|
+
error_message = [error_head, error_body].join("\n")
|
318
|
+
|
319
|
+
expect{ DataClass(value: nil).with_constraint(value: -> { _1 > 0 }) }
|
320
|
+
.to raise_error(Lab42::DataClass::ConstraintError, /#{error_message}/)
|
321
|
+
```
|
322
|
+
|
323
|
+
And defining constraints for undefined attributes is not the best of ideas
|
324
|
+
```ruby
|
325
|
+
expect { DataClass(a: 1).with_constraint(b: -> {true}) }
|
326
|
+
.to raise_error(ArgumentError, "constraints cannot be defined for undefined attributes [:b]")
|
327
|
+
```
|
328
|
+
|
329
|
+
|
330
|
+
#### Context: Convenience Constraints
|
331
|
+
|
332
|
+
Often repeating patterns are implemented as non lambda constraints, depending on the type of a constraint
|
333
|
+
it is implicitly converted to a lambda as specified below:
|
334
|
+
|
335
|
+
Given a shortcut for our `ConstraintError`
|
336
|
+
```ruby
|
337
|
+
let(:constraint_error) { Lab42::DataClass::ConstraintError }
|
338
|
+
let(:positive) { DataClass(:value) }
|
339
|
+
```
|
340
|
+
|
341
|
+
##### Symbols
|
342
|
+
|
343
|
+
... are sent to the value of the attribute, this is not very surprising of course ;)
|
344
|
+
|
345
|
+
Then a first implementation of `Positive`
|
346
|
+
```ruby
|
347
|
+
positive_by_symbol = positive.with_constraint(value: :positive?)
|
348
|
+
|
349
|
+
expect(positive_by_symbol.new(value: 1).value).to eq(1)
|
350
|
+
expect{positive_by_symbol.new(value: 0)}.to raise_error(constraint_error)
|
351
|
+
```
|
352
|
+
|
353
|
+
##### Arrays
|
354
|
+
|
355
|
+
... are also sent to the value of the attribute, this time we can provide paramaters
|
356
|
+
And we can implement a different form of `Positive`
|
357
|
+
```ruby
|
358
|
+
positive_by_ary = positive.with_constraint(value: [:>, 0])
|
359
|
+
|
360
|
+
expect(positive_by_ary.new(value: 1).value).to eq(1)
|
361
|
+
expect{positive_by_ary.new(value: 0)}.to raise_error(constraint_error)
|
362
|
+
```
|
363
|
+
|
364
|
+
If however we are interested in membership we have to wrap the `Array` into a `Set`
|
365
|
+
|
366
|
+
##### Membership
|
367
|
+
|
368
|
+
And this works with a `Set`
|
369
|
+
```ruby
|
370
|
+
positive_by_set = positive.with_constraint(value: Set.new([*1..10]))
|
371
|
+
|
372
|
+
expect(positive_by_set.new(value: 1).value).to eq(1)
|
373
|
+
expect{positive_by_set.new(value: 0)}.to raise_error(constraint_error)
|
374
|
+
```
|
375
|
+
|
376
|
+
And also with a `Range`
|
377
|
+
```ruby
|
378
|
+
positive_by_range = positive.with_constraint(value: 1..Float::INFINITY)
|
379
|
+
|
380
|
+
expect(positive_by_range.new(value: 1).value).to eq(1)
|
381
|
+
expect{positive_by_range.new(value: 0)}.to raise_error(constraint_error)
|
382
|
+
```
|
383
|
+
|
384
|
+
##### Regexen
|
385
|
+
|
386
|
+
This seems quite obvious, and of course it works
|
387
|
+
|
388
|
+
Then we can also have a regex based constraint
|
389
|
+
```ruby
|
390
|
+
vowel = DataClass(:word).with_constraint(word: /[aeiou]/)
|
391
|
+
|
392
|
+
expect(vowel.new(word: "alpha").word).to eq("alpha")
|
393
|
+
expect{vowel.new(word: "krk")}.to raise_error(constraint_error)
|
394
|
+
```
|
395
|
+
|
396
|
+
##### Other callable objects as constraints
|
397
|
+
|
398
|
+
|
399
|
+
Then we can also use instance methods to implement our `Positive`
|
400
|
+
```ruby
|
401
|
+
positive_by_instance_method = positive.with_constraint(value: Fixnum.instance_method(:positive?))
|
402
|
+
|
403
|
+
expect(positive_by_instance_method.new(value: 1).value).to eq(1)
|
404
|
+
expect{positive_by_instance_method.new(value: 0)}.to raise_error(constraint_error)
|
405
|
+
```
|
406
|
+
|
407
|
+
Or we can use methods to implement it
|
408
|
+
```ruby
|
409
|
+
positive_by_method = positive.with_constraint(value: 0.method(:<))
|
410
|
+
|
411
|
+
expect(positive_by_method.new(value: 1).value).to eq(1)
|
412
|
+
expect{positive_by_method.new(value: 0)}.to raise_error(constraint_error)
|
413
|
+
```
|
414
|
+
|
415
|
+
#### Context: Global Constraints aka __Validations__
|
416
|
+
|
417
|
+
So far we have only speculated about constraints concerning one attribute, however sometimes we want
|
418
|
+
to have arbitrary constraints which can only be calculated by access to more attributes
|
419
|
+
|
420
|
+
Given a `Point` DataClass
|
421
|
+
```ruby
|
422
|
+
let(:point) { DataClass(:x, :y).validate{ |point| point.x > point.y } }
|
423
|
+
let(:validation_error) { Lab42::DataClass::ValidationError }
|
424
|
+
```
|
425
|
+
|
426
|
+
Then we will get a `ValidationError` if we construct a point left of the main diagonal
|
427
|
+
```ruby
|
428
|
+
expect{ point.new(x: 0, y: 1) }
|
429
|
+
.to raise_error(validation_error)
|
430
|
+
```
|
431
|
+
|
432
|
+
But as validation might need more than the default values we will not execute them at compile time
|
433
|
+
```ruby
|
434
|
+
expect{ DataClass(x: 0, y: 0).validate{ |inst| inst.x > inst.y } }
|
435
|
+
.to_not raise_error
|
436
|
+
```
|
437
|
+
|
438
|
+
And we can name validations to get better error messages
|
439
|
+
```ruby
|
440
|
+
better_point = DataClass(:x, :y).validate(:too_left){ |point| point.x > point.y }
|
441
|
+
ok_point = better_point.new(x: 1, y: 0)
|
442
|
+
expect{ ok_point.merge(y: 1) }
|
443
|
+
.to raise_error(validation_error, "too_left")
|
444
|
+
```
|
445
|
+
|
446
|
+
And remark how bad unnamed validation errors might be
|
447
|
+
```ruby
|
448
|
+
error_message_rgx = %r{
|
449
|
+
\#<Proc:0x[0-9a-f]+ \s .* spec/speculations/README_spec\.rb: \d+ > \z
|
450
|
+
}x
|
451
|
+
expect{ point.new(x: 0, y: 1) }
|
452
|
+
.to raise_error(validation_error, error_message_rgx)
|
453
|
+
```
|
454
|
+
|
283
455
|
## Context: `Pair` and `Triple`
|
284
456
|
|
285
457
|
Two special cases of a `DataClass` which behave like `Tuple` of size 2 and 3 in _Elixir_
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lab42
|
4
|
+
module DataClass
|
5
|
+
class Proxy
|
6
|
+
module Constraints
|
7
|
+
module Maker
|
8
|
+
extend self
|
9
|
+
|
10
|
+
def make_constraint(constraint)
|
11
|
+
case constraint
|
12
|
+
when Proc, Method
|
13
|
+
constraint
|
14
|
+
when Symbol
|
15
|
+
-> { _1.send(constraint) }
|
16
|
+
when Array
|
17
|
+
-> { _1.send(*constraint) }
|
18
|
+
when Regexp
|
19
|
+
-> { constraint.match?(_1) }
|
20
|
+
when UnboundMethod
|
21
|
+
-> { constraint.bind(_1).() }
|
22
|
+
else
|
23
|
+
_make_member_constraint(constraint)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def _make_member_constraint(constraint)
|
30
|
+
if constraint.respond_to?(:member?)
|
31
|
+
-> { constraint.member?(_1) }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
# SPDX-License-Identifier: Apache-2.0
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "constraints/maker"
|
4
|
+
module Lab42
|
5
|
+
module DataClass
|
6
|
+
class Proxy
|
7
|
+
module Constraints
|
8
|
+
def check_constraints_against_defaults(constraints)
|
9
|
+
errors = constraints
|
10
|
+
.map(&_check_constraint_against_default)
|
11
|
+
.compact
|
12
|
+
raise ConstraintError, errors.join("\n\n") unless errors.empty?
|
13
|
+
end
|
14
|
+
|
15
|
+
def define_constraint
|
16
|
+
->((attr, constraint)) do
|
17
|
+
if members.member?(attr)
|
18
|
+
constraints[attr] << Maker.make_constraint(constraint)
|
19
|
+
nil
|
20
|
+
else
|
21
|
+
attr
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def _check_constraint_against_default
|
29
|
+
->((attr, constraint)) do
|
30
|
+
if defaults.key?(attr)
|
31
|
+
_check_constraint_against_default_value(attr, defaults[attr], constraint)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def _check_constraint_against_default_value(attr, value, constraint)
|
37
|
+
unless constraint.(value)
|
38
|
+
"default value #{value.inspect} is not allowed for attribute #{attr.inspect}"
|
39
|
+
end
|
40
|
+
rescue StandardError => e
|
41
|
+
"constraint error during validation of default value of attribute #{attr.inspect}\n #{e.message}"
|
42
|
+
end
|
43
|
+
|
44
|
+
def _check_constraints_for_attr!
|
45
|
+
->((k, v)) do
|
46
|
+
constraints[k]
|
47
|
+
.map(&_check_constraint!(k, v))
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def _check_constraint!(attr, value)
|
52
|
+
->(constraint) do
|
53
|
+
"value #{value.inspect} is not allowed for attribute #{attr.inspect}" unless constraint.(value)
|
54
|
+
rescue StandardError => e
|
55
|
+
"constraint error during validation of attribute #{attr.inspect}\n #{e.message}"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def _check_constraints!(params)
|
60
|
+
errors = params
|
61
|
+
.flat_map(&_check_constraints_for_attr!)
|
62
|
+
.compact
|
63
|
+
|
64
|
+
raise ConstraintError, errors.join("\n\n") unless errors.empty?
|
65
|
+
end
|
66
|
+
|
67
|
+
def _define_with_constraint
|
68
|
+
proxy = self
|
69
|
+
->(*) do
|
70
|
+
define_method :with_constraint do |**constraints|
|
71
|
+
errors = constraints.map(&proxy.define_constraint).compact
|
72
|
+
unless errors.empty?
|
73
|
+
raise ArgumentError,
|
74
|
+
"constraints cannot be defined for undefined attributes #{errors.inspect}"
|
75
|
+
end
|
76
|
+
|
77
|
+
proxy.check_constraints_against_defaults(constraints)
|
78
|
+
self
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
# SPDX-License-Identifier: Apache-2.0
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lab42
|
4
|
+
module DataClass
|
5
|
+
class Proxy
|
6
|
+
module Validations
|
7
|
+
def validate!(instance)
|
8
|
+
errors = validations
|
9
|
+
.map(&_check_validation!(instance))
|
10
|
+
.compact
|
11
|
+
|
12
|
+
raise ValidationError, errors.join("\n") unless errors.empty?
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def _define_with_validations
|
18
|
+
proxy = self
|
19
|
+
->(*) do
|
20
|
+
define_method :validate do |name = nil, &block|
|
21
|
+
proxy.validations << [name, block]
|
22
|
+
self
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def _check_validation!(instance)
|
28
|
+
->((name, validation)) do
|
29
|
+
unless validation.(instance)
|
30
|
+
name || validation
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
# SPDX-License-Identifier: Apache-2.0
|
@@ -1,16 +1,23 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'set'
|
4
|
+
require_relative 'proxy/constraints'
|
5
|
+
require_relative 'proxy/validations'
|
4
6
|
require_relative 'proxy/mixin'
|
5
7
|
module Lab42
|
6
8
|
module DataClass
|
7
9
|
class Proxy
|
8
|
-
|
10
|
+
include Constraints, Validations
|
11
|
+
|
12
|
+
attr_reader :actual_params, :all_params, :block, :constraints, :defaults, :klass, :members, :positionals
|
9
13
|
|
10
14
|
def check!(**params)
|
11
15
|
@actual_params = params
|
16
|
+
@all_params = defaults.merge(params)
|
12
17
|
raise ArgumentError, "missing initializers for #{_missing_initializers}" unless _missing_initializers.empty?
|
13
18
|
raise ArgumentError, "illegal initializers #{_illegal_initializers}" unless _illegal_initializers.empty?
|
19
|
+
|
20
|
+
_check_constraints!(all_params)
|
14
21
|
end
|
15
22
|
|
16
23
|
def define_class!
|
@@ -32,14 +39,20 @@ module Lab42
|
|
32
39
|
.to_h
|
33
40
|
end
|
34
41
|
|
42
|
+
def validations
|
43
|
+
@__validations__ ||= []
|
44
|
+
end
|
45
|
+
|
35
46
|
private
|
36
47
|
def initialize(*args, **kwds, &blk)
|
37
48
|
@klass = Class.new
|
38
49
|
|
50
|
+
@constraints = Hash.new { |h, k| h[k] = [] }
|
51
|
+
|
39
52
|
@block = blk
|
40
53
|
@defaults = kwds
|
41
54
|
@members = Set.new(args + kwds.keys)
|
42
|
-
# TODO: Check for all symbols and no duplicates ⇒ v0.
|
55
|
+
# TODO: Check for all symbols and no duplicates ⇒ v0.5.1
|
43
56
|
@positionals = args
|
44
57
|
end
|
45
58
|
|
@@ -64,6 +77,7 @@ module Lab42
|
|
64
77
|
define_method :initialize do |**params|
|
65
78
|
proxy.check!(**params)
|
66
79
|
proxy.init(self, **params)
|
80
|
+
proxy.validate!(self)
|
67
81
|
end
|
68
82
|
end
|
69
83
|
end
|
@@ -78,12 +92,19 @@ module Lab42
|
|
78
92
|
end
|
79
93
|
|
80
94
|
def _define_methods
|
81
|
-
|
82
|
-
|
95
|
+
class << klass; self end
|
96
|
+
.tap { |singleton| _define_singleton_methods(singleton) }
|
83
97
|
klass.module_eval(&_define_to_h)
|
84
98
|
klass.module_eval(&_define_merge)
|
85
99
|
end
|
86
100
|
|
101
|
+
def _define_singleton_methods(singleton)
|
102
|
+
singleton.module_eval(&_define_freezing_constructor)
|
103
|
+
singleton.module_eval(&_define_to_proc)
|
104
|
+
singleton.module_eval(&_define_with_constraint)
|
105
|
+
singleton.module_eval(&_define_with_validations)
|
106
|
+
end
|
107
|
+
|
87
108
|
def _define_to_h
|
88
109
|
proxy = self
|
89
110
|
->(*) do
|
data/lib/lab42/data_class.rb
CHANGED
metadata
CHANGED
@@ -1,20 +1,21 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: lab42_data_class
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
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-
|
11
|
+
date: 2022-02-23 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description: |
|
14
14
|
An Immutable DataClass for Ruby
|
15
15
|
|
16
16
|
Exposes a class factory function `Kernel::DataClass` and a class
|
17
|
-
modifer `Module#dataclass'
|
17
|
+
modifer `Module#dataclass', also creates two _tuple_ classes, `Pair` and
|
18
|
+
`Triple`
|
18
19
|
email: robert.dober@gmail.com
|
19
20
|
executables: []
|
20
21
|
extensions: []
|
@@ -23,8 +24,13 @@ files:
|
|
23
24
|
- LICENSE
|
24
25
|
- README.md
|
25
26
|
- lib/lab42/data_class.rb
|
27
|
+
- lib/lab42/data_class/constraint_error.rb
|
26
28
|
- lib/lab42/data_class/proxy.rb
|
29
|
+
- lib/lab42/data_class/proxy/constraints.rb
|
30
|
+
- lib/lab42/data_class/proxy/constraints/maker.rb
|
27
31
|
- lib/lab42/data_class/proxy/mixin.rb
|
32
|
+
- lib/lab42/data_class/proxy/validations.rb
|
33
|
+
- lib/lab42/data_class/validation_error.rb
|
28
34
|
- lib/lab42/data_class/version.rb
|
29
35
|
- lib/lab42/eq_and_patterns.rb
|
30
36
|
- lib/lab42/pair.rb
|
@@ -33,7 +39,7 @@ homepage: https://github.com/robertdober/lab42_data_class
|
|
33
39
|
licenses:
|
34
40
|
- Apache-2.0
|
35
41
|
metadata: {}
|
36
|
-
post_install_message:
|
42
|
+
post_install_message:
|
37
43
|
rdoc_options: []
|
38
44
|
require_paths:
|
39
45
|
- lib
|
@@ -49,7 +55,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
49
55
|
version: '0'
|
50
56
|
requirements: []
|
51
57
|
rubygems_version: 3.3.3
|
52
|
-
signing_key:
|
58
|
+
signing_key:
|
53
59
|
specification_version: 4
|
54
60
|
summary: Finally a dataclass in ruby
|
55
61
|
test_files: []
|