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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6be60bd36bb2a9a538eb968067ff72eec653a4e9bc7dc3c8904558f803668e3
4
- data.tar.gz: 4c1e75ab8907ce56ebcb71dd56d705afd9a1127e905ffe5344d846ddd0ade90a
3
+ metadata.gz: 70b03224373a4bdfe219cfb5d71d735f4a5b4d02c57531ea0f80a1729e542ce5
4
+ data.tar.gz: b04020b335831a2c8fb8070bb17056874bb9103c5dda7b0e313da93c8afd80a0
5
5
  SHA512:
6
- metadata.gz: 33d4995e0048d390486d01bc3f07ad1be0e0dd5efbcf9693f656162e8691645be2e0b11e60df41defc771f9fc7108f1b35cef58aa4e0102b4912136094e06202
7
- data.tar.gz: 02e403af2357e1b6b06e34afe9909777ab3f64a27d51843387ed20d1d349d96f7c9383fe22da386b42e7e5510180e6bc9a6a3d17c0b86cf16d1ff3f6cd02c8c3
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
- An immutable Dataclass, Tuples and Triples
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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab42
4
+ module DataClass
5
+ class ConstraintError < RuntimeError
6
+ end
7
+ end
8
+ end
9
+ # SPDX-License-Identifier: Apache-2.0
@@ -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
- attr_reader :actual_params, :block, :defaults, :klass, :members, :positionals
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.1.1
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
- DataClass(*proxy.positionals, **proxy.defaults, &proxy.block)
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
- (class << klass; self end).module_eval(&_define_freezing_constructor)
84
- (class << klass; self end).module_eval(&_define_to_proc)
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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lab42
4
+ module DataClass
5
+ class ValidationError < 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.4.0"
5
+ VERSION = "0.5.1"
6
6
  end
7
7
  end
8
8
  # SPDX-License-Identifier: Apache-2.0
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative './data_class/constraint_error'
4
+ require_relative './data_class/validation_error'
3
5
  require_relative './data_class/proxy'
4
6
  require_relative './pair'
5
7
  require_relative './triple'
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.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-19 00:00:00.000000000 Z
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