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