blood_contracts-core 0.3.5 → 0.4.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.
@@ -1,145 +1,168 @@
1
- module BloodContracts
2
- module Core
3
- class Refined
4
- class << self
5
- def or_a(other_type)
6
- BC::Sum.new(self, other_type)
7
- end
8
- alias :or_an :or_a
9
- alias :| :or_a
10
-
11
- def and_then(other_type)
12
- BC::Pipe.new(self, other_type)
13
- end
14
- alias :> :and_then
15
-
16
- def match(*args, **kwargs)
17
- if block_given?
18
- new(*args, **kwargs).match { |*subargs| yield(*subargs) }
19
- else
20
- new(*args, **kwargs).match
21
- end
22
- end
23
- alias :call :match
24
-
25
- def ===(object)
26
- return object.to_ary.any?(self) if object.is_a?(Tuple)
27
- super
28
- end
29
-
30
- def set(**kwargs)
31
- kwargs.each do |setting, value|
32
- send(:"#{setting}=", value)
33
- end
34
- self
35
- end
36
-
37
- attr_accessor :failure_klass
38
- def inherited(new_klass)
39
- new_klass.failure_klass ||= ContractFailure
40
- end
41
- end
42
-
43
- attr_accessor :context
44
- attr_reader :errors, :value
45
-
46
- def initialize(value, context: Hash.new { |h,k| h[k] = Hash.new }, **)
47
- @errors = []
48
- @context = context
49
- @value = value
50
- end
51
-
52
- def match
53
- return @match if defined? @match
54
- return @match = (yield || self) if block_given?
55
- return @match = (_match || self) if respond_to?(:_match)
56
- self
57
- end
58
- alias :call :match
59
-
60
- def valid?
61
- match.errors.empty?
62
- end
63
- def invalid?; !valid?; end
64
-
65
- def unpack
66
- return @unpack if defined? @unpack
67
- raise "This is not what you're looking for" if match.invalid?
68
- return yield(match) if block_given?
69
- return @unpack = _unpack(match) if respond_to?(:_unpack)
70
-
71
- unpack_refined @value
1
+ module BloodContracts::Core
2
+ # Base class for refinement type validations
3
+ class Refined
4
+ class << self
5
+ # Compose types in a Sum check
6
+ # Sum passes data from type to type in parallel, only one type
7
+ # have to match
8
+ #
9
+ # @return [BC::Sum]
10
+ #
11
+ def or_a(other_type)
12
+ BC::Sum.new(self, other_type)
13
+ end
14
+ alias or_an or_a
15
+ alias | or_a
16
+
17
+ # Compose types in a Pipe check
18
+ # Pipe passes data from type to type sequentially
19
+ #
20
+ # @return [BC::Pipe]
21
+ #
22
+ def and_then(other_type)
23
+ BC::Pipe.new(self, other_type)
24
+ end
25
+ alias > and_then
26
+
27
+ # Validate data over refinement type conditions
28
+ # Result is ALWAYS a Refined, but in cases when validation failed,
29
+ # we return ContractFailure ancestor or ContractFailure itself
30
+ # (which is Refined anyway)
31
+ #
32
+ # @return [Refined]
33
+ #
34
+ def match(*args, **kwargs, &block)
35
+ instance = new(*args, **kwargs)
36
+ match = instance.match(&block) || instance
37
+ instance.instance_variable_set(:@match, match)
38
+ match
39
+ end
40
+ alias call match
41
+
42
+ # Override of case equality operator, to handle Tuple correctly
43
+ def ===(object)
44
+ return object.to_ary.any?(self) if object.is_a?(Tuple)
45
+ super
72
46
  end
73
47
 
74
- def failure(error = nil, errors: @errors, **kwargs)
75
- error ||= kwargs unless kwargs.empty?
76
- errors << error if error
77
- self.class.failure_klass.new(
78
- { self.class => errors }, context: context
79
- )
80
- end
81
-
82
- protected
83
-
84
- def refined?(object)
85
- object.class < BloodContracts::Core::Refined
48
+ # Accessor to define alternative to ContractFailure for #failure
49
+ # method to use
50
+ #
51
+ # @return [ContractFailure]
52
+ #
53
+ attr_accessor :failure_klass
54
+ def inherited(new_klass)
55
+ new_klass.failure_klass ||= ContractFailure
86
56
  end
57
+ end
87
58
 
88
- def share_context_with(match)
89
- match.context = @context.merge!(match.context)
90
- yield(match.context)
91
- end
59
+ # Matching context, contains extra debugging and output data
60
+ #
61
+ # @return [Hash<Symbol, Object>]
62
+ #
63
+ attr_accessor :context
64
+
65
+ # List of errors per type
66
+ #
67
+ # @return [Array<Hash<Refined, String>>]
68
+ #
69
+ attr_reader :errors
70
+
71
+ # Refinement type constructor
72
+ #
73
+ # @param [Object] value that Refined holds and should match
74
+ # @option [Hash<Symbol, Object>] context to share between types
75
+ #
76
+ def initialize(value, context: Hash.new { |h, k| h[k] = {} }, **)
77
+ @errors = []
78
+ @context = context
79
+ @value = value
80
+ end
92
81
 
93
- def refine_value(value)
94
- refined?(value) ? value.match : Anything.new(value)
95
- end
82
+ # The type which is the result of data matching process
83
+ #
84
+ # @return [BC::Refined]
85
+ #
86
+ protected def match
87
+ fail NotImplementedError
88
+ end
89
+ alias call match
96
90
 
97
- def unpack_refined(value)
98
- refined?(value) ? value.unpack : value
99
- end
91
+ # Transform the value before unpacking
92
+ protected def mapped
93
+ value
94
+ end
100
95
 
101
- def errors_by_type(matches)
102
- matches.map(&:errors).reduce(:+).delete_if(&:empty?)
103
- end
96
+ # Checks whether the data matches the expectations or not
97
+ #
98
+ # @return [Boolean]
99
+ #
100
+ def valid?
101
+ @match.errors.empty?
104
102
  end
105
103
 
106
- class ContractFailure < Refined
107
- def initialize(value = nil, **)
108
- super
109
- return unless @value
110
- @context[:errors] = (@context[:errors].to_a << @value.to_h)
111
- end
104
+ # Checks whether the data matches the expectations or not
105
+ # (just negation of #valid?)
106
+ #
107
+ # @return [Boolean]
108
+ #
109
+ def invalid?
110
+ !valid?
111
+ end
112
112
 
113
- def errors
114
- @context[:errors].to_a
115
- end
113
+ # Unpack the original value from the refinement type
114
+ #
115
+ # @return [Object]
116
+ #
117
+ def unpack
118
+ @unpack ||= mapped
119
+ end
116
120
 
117
- def errors_h
118
- errors.reduce(:merge)
119
- end
120
- alias :to_h :errors_h
121
+ protected
122
+
123
+ # Helper to build a ContractFailure with shared context
124
+ #
125
+ # @return [ContractFailure]
126
+ #
127
+ def failure(error = nil, errors: @errors, **kwargs)
128
+ error ||= kwargs unless kwargs.empty?
129
+ errors << error if error
130
+ self.class.failure_klass.new(
131
+ { self.class => errors }, context: @context
132
+ )
133
+ end
121
134
 
122
- def match
123
- self
124
- end
135
+ # Helper to turn value into raw data
136
+ #
137
+ # @return [Object]
138
+ def value
139
+ unpack_refined(@value)
140
+ end
125
141
 
126
- def unpack
127
- context
128
- end
142
+ def refined?(object)
143
+ object.class < BloodContracts::Core::Refined
129
144
  end
130
145
 
131
- class Anything < Refined
132
- def match
133
- self
134
- end
146
+ # FIXME: do we need it?
147
+ def share_context_with(match)
148
+ match.context = @context.merge!(match.context)
149
+ yield(match.context)
150
+ end
135
151
 
136
- def valid?
137
- true
138
- end
152
+ # Turn data into refinement type if it is not already
153
+ #
154
+ # @return [Object]
155
+ #
156
+ def refine_value(value)
157
+ refined?(value) ? value.match : Anything.new(value)
158
+ end
139
159
 
140
- def unpack
141
- @value
142
- end
160
+ # Turn value into raw data if it is refined
161
+ #
162
+ # @return [Object]
163
+ #
164
+ def unpack_refined(value)
165
+ refined?(value) ? value.unpack : value
143
166
  end
144
167
  end
145
168
  end
@@ -1,67 +1,99 @@
1
- require 'set'
1
+ require "set"
2
2
 
3
- module BloodContracts
4
- module Core
5
- class Sum < Refined
6
- class << self
7
- attr_reader :sum_of, :finalized
3
+ module BloodContracts::Core
4
+ # Meta refinement type, represents sum of several refinement types
5
+ class Sum < Refined
6
+ class << self
7
+ # Represents list of types in the sum
8
+ #
9
+ # @return [Array<Refined>]
10
+ #
11
+ attr_reader :sum_of
8
12
 
9
- def new(*args)
10
- return super(*args) if finalized
13
+ # Metaprogramming around constructor
14
+ # Turns input into Sum meta-class
15
+ #
16
+ # @param (see #initialze)
17
+ #
18
+ # rubocop:disable Style/SingleLineMethods
19
+ def new(*args)
20
+ return super(*args) if @finalized
11
21
 
12
- new_sum = args.reduce([]) { |acc, type| type.respond_to?(:sum_of) ? acc + type.sum_of.to_a : acc << type }
13
-
14
- sum = Class.new(Sum) { def inspect; super; end }
15
- sum.instance_variable_set(:@sum_of, ::Set.new(new_sum.compact))
16
- sum.instance_variable_set(:@finalized, true)
17
- sum
22
+ new_sum = args.reduce([]) do |acc, type|
23
+ type.respond_to?(:sum_of) ? acc + type.sum_of.to_a : acc << type
18
24
  end
19
25
 
20
- def or_a(other_type)
21
- sum = Class.new(Sum) { def inspect; super; end }
22
- new_sum = self.sum_of.to_a
23
- if other_type.respond_to?(:sum_of)
24
- new_sum += other_type.sum_of.to_a
25
- else
26
- new_sum << other_type
27
- end
28
- sum.instance_variable_set(:@sum_of, ::Set.new(new_sum.compact))
29
- sum.instance_variable_set(:@finalized, true)
30
- sum
31
- end
32
- alias :or_an :or_a
33
- alias :| :or_a
26
+ sum = Class.new(Sum) { def inspect; super; end }
27
+ finalize!(sum, new_sum)
28
+ sum
29
+ end
34
30
 
35
- def inspect
36
- return super if self.name
37
- "Sum(#{self.sum_of.map(&:inspect).join(',')})"
31
+ # Compose types in a Sum check
32
+ # Sum passes data from type to type in parallel, only one type
33
+ # have to match
34
+ #
35
+ # @return [BC::Sum]
36
+ #
37
+ def or_a(other_type)
38
+ sum = Class.new(Sum) { def inspect; super; end }
39
+ new_sum = sum_of.to_a
40
+ if other_type.respond_to?(:sum_of)
41
+ new_sum += other_type.sum_of.to_a
42
+ else
43
+ new_sum << other_type
38
44
  end
45
+ finalize!(sum, new_sum)
46
+ sum
39
47
  end
48
+ # rubocop:enable Style/SingleLineMethods
49
+ alias or_an or_a
50
+ alias | or_a
40
51
 
41
- def match
42
- super do
43
- or_matches = self.class.sum_of.map do |type|
44
- match = type.match(@value, context: @context)
45
- end
52
+ # @private
53
+ private def finalize!(new_class, new_sum)
54
+ new_class.instance_variable_set(:@sum_of, ::Set.new(new_sum.compact))
55
+ new_class.instance_variable_set(:@finalized, true)
56
+ end
46
57
 
47
- if (match = or_matches.find(&:valid?))
48
- match
49
- else
50
- or_matches.first
51
- # just use the context
52
- # ContractFailure.new(context: context)
53
- end
54
- end
58
+ # Returns text representation of Sum meta-class
59
+ #
60
+ # @return [String]
61
+ #
62
+ def inspect
63
+ return super if name
64
+ "Sum(#{sum_of.map(&:inspect).join(',')})"
55
65
  end
66
+ end
56
67
 
57
- def errors
58
- match.errors
68
+ # The type which is the result of data matching process
69
+ # For Tuple it verifies that all the attributes data are valid types
70
+ #
71
+ # @return [BC::Refined]
72
+ #
73
+ def match
74
+ or_matches = self.class.sum_of.map do |type|
75
+ type.match(@value, context: @context)
59
76
  end
60
77
 
61
- def inspect
62
- "#<sum #{self.class.name} is #{self.class.sum_of.to_a.join(' or ')} (value=#{@value})>"
78
+ if (match = or_matches.find(&:valid?))
79
+ match
80
+ else
81
+ failure(:no_matches)
63
82
  end
64
83
  end
65
- Or = Sum
84
+
85
+ # List of errors per type during the matching
86
+ #
87
+ # @return [Array<Hash<Refined, String>>]
88
+ #
89
+ def errors
90
+ @context[:errors]
91
+ end
92
+
93
+ # @private
94
+ private def inspect
95
+ "#<sum #{self.class.name} is #{self.class.sum_of.to_a.join(' or ')}"\
96
+ " (value=#{@value})>"
97
+ end
66
98
  end
67
99
  end
@@ -1,82 +1,167 @@
1
- module BloodContracts
2
- module Core
3
- class Tuple < Refined
4
- class << self
5
- attr_reader :attributes, :names, :finalized
6
-
7
- def new(*args)
8
- return super(*args) if finalized
9
- if args.last.is_a?(Hash)
10
- names = args.pop.delete(:names)
11
- end
12
-
13
- raise ArgumentError unless args.all?(Class)
14
- tuple = Class.new(Tuple) { def inspect; super; end }
15
- tuple.instance_variable_set(:@attributes, args)
16
- tuple.instance_variable_set(:@names, names.to_a)
17
- tuple.instance_variable_set(:@finalized, true)
18
- tuple
19
- end
1
+ module BloodContracts::Core
2
+ # Meta refinement type, represents product of several refinement types
3
+ class Tuple < Refined
4
+ class << self
5
+ # List of types in the Tuple
6
+ #
7
+ # @return [Array<Refined>]
8
+ attr_reader :attributes
20
9
 
21
- private
10
+ # Names of attributes
11
+ #
12
+ # @return [Array<Symbol>]
13
+ #
14
+ attr_reader :names
22
15
 
23
- attr_writer :names
24
- end
16
+ # Metaprogramming around constructor
17
+ # Turns input into Tuple meta-class
18
+ #
19
+ # @param (see #initialze)
20
+ #
21
+ # rubocop:disable Style/SingleLineMethods
22
+ def new(*args, **kwargs, &block)
23
+ return super(*args, **kwargs) if @finalized
24
+ names = args.pop.delete(:names) if args.last.is_a?(Hash)
25
25
 
26
- attr_reader :values
27
- def initialize(*values, context: Hash.new { |h,k| h[k] = Hash.new }, **)
28
- _validate_args!(values)
29
- @errors = []
30
- @context = context
31
- @values = values
26
+ raise ArgumentError unless args.all? { |type| type < Refined }
27
+ tuple = Class.new(Tuple) { def inspect; super; end }
28
+ tuple.instance_variable_set(:@attributes, args)
29
+ tuple.instance_variable_set(:@names, names.to_a)
30
+ tuple.instance_variable_set(:@finalized, true)
31
+ tuple.class_eval(&block) if block_given?
32
+ tuple
32
33
  end
34
+ # rubocop:enable Style/SingleLineMethods
33
35
 
34
- def match
35
- super do
36
- @matches = self.class.attributes.zip(values).map do |(type, value)|
37
- type.match(value, context: @context)
38
- end
39
- next self if (failure = @matches.find(&:invalid?)).nil?
40
- failure
36
+ # Helper which registers attribute in the Tuple, also defines a reader
37
+ def attribute(name, type)
38
+ raise ArgumentError unless type < Refined
39
+ @attributes << type
40
+ @names << name
41
+ define_method(name) do
42
+ match.context.dig(:attributes, name)
41
43
  end
42
44
  end
43
45
 
44
- def unpack
45
- super { |match| @matches.map(&:unpack) }
46
- end
47
- alias :to_ary :unpack
48
- alias :to_a :unpack
49
-
50
- def unpack_h
51
- @unpack_h ||= Hash[
52
- unpack.map.with_index do |unpacked, index|
53
- key = self.class.names[index] || index
54
- [key, unpacked]
55
- end
56
- ]
57
- end
58
- alias :to_hash :unpack_h
59
- alias :to_h :unpack_h
60
-
61
- private def values_by_names
62
- if self.class.names.empty?
63
- self.values
64
- else
65
- self.class.names.zip(values).map { |k, v| [k, v].join('=') }
66
- end
46
+ # Accessor to define alternative to ContractFailure for #failure
47
+ # method to use
48
+ #
49
+ # @return [ContractFailure]
50
+ #
51
+ attr_accessor :failure_klass
52
+ def inherited(new_klass)
53
+ new_klass.instance_variable_set(:@attributes, [])
54
+ new_klass.instance_variable_set(:@names, [])
55
+ new_klass.instance_variable_set(:@finalized, true)
56
+ new_klass.failure_klass ||= TupleContractFailure
57
+ super
67
58
  end
59
+ end
60
+
61
+
62
+ # List of values in Tuple
63
+ #
64
+ # @return [Array<Object>]
65
+ #
66
+ attr_reader :values
68
67
 
69
- private def _validate_args!(values)
70
- return if values.size == self.class.attributes.size
71
- raise ArgumentError, <<~MESSAGE
72
- wrong number of arguments (given #{values.size}, \
73
- expected #{self.class.attributes.size})
74
- MESSAGE
68
+ # Tuple constructor, builds Tuple from list of data values
69
+ #
70
+ # @param [Array<Object>] *values that we'll keep inside the Tuple
71
+ # @option [Hash<Symbol, Object>] context to share between types
72
+ #
73
+ def initialize(*values, context: Hash.new { |h, k| h[k] = {} }, **)
74
+ @context = context
75
+ @context[:attributes] ||= {}
76
+
77
+ additional_context = values.last if values.last.is_a?(Hash)
78
+ additional_context ||= {}
79
+
80
+ @values = parse_values_from_context(context.merge(additional_context))
81
+ @values ||= values
82
+
83
+ @errors = []
84
+ end
85
+
86
+ # The type which is the result of data matching process
87
+ # For Tuple it verifies that all the attributes data are valid types
88
+ #
89
+ # @return [BC::Refined]
90
+ #
91
+ def match
92
+ @matches = attributes_enumerator.map do |(type, value), index|
93
+ attribute_name = self.class.names[index]
94
+ attributes.store(attribute_name, type.match(value, context: @context))
75
95
  end
96
+ return if @matches.find(&:invalid?).nil?
97
+ failure(:invalid_tuple)
98
+ end
99
+
100
+ # Turns match into array of unpacked values
101
+ #
102
+ # @return [Array<Object>]
103
+ #
104
+ def mapped
105
+ @matches.map(&:unpack)
106
+ end
107
+
108
+ # (see #mapped)
109
+ alias to_ary unpack
110
+
111
+ # (see #mapped)
112
+ alias to_a unpack
113
+
114
+ # Unpacked value in form of a hash per attribute
115
+ #
116
+ # @return [Hash<String, ContractFailure>]
117
+ #
118
+ def unpack_h
119
+ @unpack_h ||= attributes.transform_values(&:unpack)
120
+ end
121
+ alias to_hash unpack_h
122
+ alias to_h unpack_h
123
+ alias unpack_attributes unpack_h
124
+
125
+ # Hash of attributes (name & type pairs)
126
+ #
127
+ # @return [Hash<String, Refined>]
128
+ #
129
+ def attributes
130
+ @context[:attributes]
131
+ end
76
132
 
77
- private def inspect
78
- "#<tuple #{self.class.name} of (#{values_by_names.join(',')})>"
133
+ # Subset of attributes which are invalid
134
+ #
135
+ # @return [Hash<String, ContractFailure>]
136
+ #
137
+ def attribute_errors
138
+ {}
139
+ end
140
+
141
+ # @private
142
+ private def parse_values_from_context(context)
143
+ return if context.empty?
144
+ return unless (self.class.names - context.keys).empty?
145
+ context.values_at(*self.class.names)
146
+ end
147
+
148
+ # @private
149
+ private def attributes_enumerator
150
+ self.class.attributes.zip(@values).each.with_index
151
+ end
152
+
153
+ # @private
154
+ private def values_by_names
155
+ if self.class.names.empty?
156
+ values
157
+ else
158
+ self.class.names.zip(values).map { |k, v| [k, v].join("=") }
79
159
  end
80
160
  end
161
+
162
+ # @private
163
+ private def inspect
164
+ "#<tuple #{self.class.name} of (#{values_by_names.join(', ')})>"
165
+ end
81
166
  end
82
167
  end