blood_contracts-core 0.3.5 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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