lab42_data_class 0.4.1 → 0.5.0

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: e64e5ffb63619de67de53f6618db6f25dc61e6a69b99fb6ae6cdc96446bbc490
4
- data.tar.gz: ccf55ee9ef5d9bd3beabba0f852910ab37294a23415df78ca800e5ae411905ab
3
+ metadata.gz: 4d8b97ca60e61ad4b3ec85555649e5538ca35ef1018bcdf2a1cd9f86d4183718
4
+ data.tar.gz: 7076ca2be4a3a8e5428b2fa0fa14523c416d491d14a8ba254842405332215dba
5
5
  SHA512:
6
- metadata.gz: c37f77d7fd444f7b64ba489e6d170b28e49ca176f74311f9aa0f269976e1561e56ba8561dd2f4b2f47cc526dc677943a98f9cd4c356c073bdc6bcebfcff29580
7
- data.tar.gz: ed8a76bd074ad26ac625b17aec759d36300d5f0ba4ebe6ed6c827ee38149d6b8303d5bca7e8c62575fad3f2c29513577c89de0c607d15d5e109a9e813de7e59c
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
- 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 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,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
- (class << klass; self end).module_eval(&_define_freezing_constructor)
82
- (class << klass; self end).module_eval(&_define_to_proc)
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
@@ -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.1"
5
+ VERSION = "0.5.0"
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.1
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-22 00:00:00.000000000 Z
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: []