lab42_data_class 0.4.0 → 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- 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 +26 -7
- 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 +9 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 70b03224373a4bdfe219cfb5d71d735f4a5b4d02c57531ea0f80a1729e542ce5
|
4
|
+
data.tar.gz: b04020b335831a2c8fb8070bb17056874bb9103c5dda7b0e313da93c8afd80a0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c360a0eb554b9356c92e0a395d09a2dd29dc76f64d2a805ef6ac8e1699a057bb01fc8a41be4bf2f34f3e3ec5d9eff336817541bc59d39f7d94c74131303e2f0d
|
7
|
+
data.tar.gz: 63395d4ede760ce63e7d92753ef370b2da666bafec309dee43f8c8549e768cc653d1976742441b583fd09a1a85535cc783834998ce494b903f2a40d380073a46
|
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 Maker.make_constraint(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,28 +77,34 @@ 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
|
70
84
|
|
71
85
|
def _define_merge
|
72
|
-
proxy = self
|
73
86
|
->(*) do
|
74
87
|
define_method :merge do |**params|
|
75
88
|
values = to_h.merge(params)
|
76
|
-
|
77
|
-
.new(**values)
|
89
|
+
self.class.new(**values)
|
78
90
|
end
|
79
91
|
end
|
80
92
|
end
|
81
93
|
|
82
94
|
def _define_methods
|
83
|
-
|
84
|
-
|
95
|
+
class << klass; self end
|
96
|
+
.tap { |singleton| _define_singleton_methods(singleton) }
|
85
97
|
klass.module_eval(&_define_to_h)
|
86
98
|
klass.module_eval(&_define_merge)
|
87
99
|
end
|
88
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
|
+
|
89
108
|
def _define_to_h
|
90
109
|
proxy = self
|
91
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.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Robert Dober
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-02-
|
11
|
+
date: 2022-02-24 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
|