plumb 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/metadata_visitor'
4
+
5
+ module Plumb
6
+ class UndefinedClass
7
+ def inspect
8
+ %(Undefined)
9
+ end
10
+
11
+ def to_s = inspect
12
+ def node_name = :undefined
13
+ end
14
+
15
+ TypeError = Class.new(::TypeError)
16
+ Undefined = UndefinedClass.new.freeze
17
+
18
+ BLANK_STRING = ''
19
+ BLANK_ARRAY = [].freeze
20
+ BLANK_HASH = {}.freeze
21
+ BLANK_RESULT = Result.wrap(Undefined)
22
+
23
+ module Callable
24
+ def metadata
25
+ MetadataVisitor.call(self)
26
+ end
27
+
28
+ def resolve(value = Undefined)
29
+ call(Result.wrap(value))
30
+ end
31
+
32
+ def parse(value = Undefined)
33
+ result = resolve(value)
34
+ raise TypeError, result.errors if result.invalid?
35
+
36
+ result.value
37
+ end
38
+
39
+ def call(result)
40
+ raise NotImplementedError, "Implement #call(Result) => Result in #{self.class}"
41
+ end
42
+ end
43
+
44
+ module Steppable
45
+ include Callable
46
+
47
+ def self.included(base)
48
+ nname = base.name.split('::').last
49
+ nname.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
50
+ nname.downcase!
51
+ nname.gsub!(/_class$/, '')
52
+ nname = nname.to_sym
53
+ base.define_method(:node_name) { nname }
54
+ end
55
+
56
+ def self.wrap(callable)
57
+ if callable.is_a?(Steppable)
58
+ callable
59
+ elsif callable.respond_to?(:call)
60
+ Step.new(callable)
61
+ else
62
+ StaticClass.new(callable)
63
+ end
64
+ end
65
+
66
+ attr_reader :name
67
+
68
+ class Name
69
+ def initialize(name)
70
+ @name = name
71
+ end
72
+
73
+ def to_s = @name
74
+
75
+ def set(n)
76
+ @name = n
77
+ self
78
+ end
79
+ end
80
+
81
+ def freeze
82
+ return self if frozen?
83
+
84
+ @name = Name.new(_inspect)
85
+ super
86
+ end
87
+
88
+ private def _inspect = self.class.name
89
+
90
+ def inspect = name.to_s
91
+
92
+ def node_name = self.class.name.split('::').last.to_sym
93
+
94
+ def defer(definition = nil, &block)
95
+ Deferred.new(definition || block)
96
+ end
97
+
98
+ def >>(other)
99
+ And.new(self, Steppable.wrap(other))
100
+ end
101
+
102
+ def |(other)
103
+ Or.new(self, Steppable.wrap(other))
104
+ end
105
+
106
+ def transform(target_type, callable = nil, &block)
107
+ self >> Transform.new(target_type, callable || block)
108
+ end
109
+
110
+ def check(errors = 'did not pass the check', &block)
111
+ a_check = lambda { |result|
112
+ block.call(result.value) ? result : result.invalid(errors:)
113
+ }
114
+
115
+ self >> a_check
116
+ end
117
+
118
+ def meta(data = {})
119
+ self >> Metadata.new(data)
120
+ end
121
+
122
+ def not(other = self)
123
+ Not.new(other)
124
+ end
125
+
126
+ def invalid(errors: nil)
127
+ Not.new(self, errors:)
128
+ end
129
+
130
+ def value(val)
131
+ self >> ValueClass.new(val)
132
+ end
133
+
134
+ def match(*args)
135
+ self >> MatchClass.new(*args)
136
+ end
137
+
138
+ def [](val) = match(val)
139
+
140
+ DefaultProc = proc do |callable|
141
+ proc do |result|
142
+ result.valid(callable.call)
143
+ end
144
+ end
145
+
146
+ def default(val = Undefined, &block)
147
+ val_type = if val == Undefined
148
+ DefaultProc.call(block)
149
+ else
150
+ Types::Static[val]
151
+ end
152
+
153
+ self | (Types::Undefined >> val_type)
154
+ end
155
+
156
+ class Node
157
+ include Steppable
158
+
159
+ attr_reader :node_name, :type, :attributes
160
+
161
+ def initialize(node_name, type, attributes = BLANK_HASH)
162
+ @node_name = node_name
163
+ @type = type
164
+ @attributes = attributes
165
+ freeze
166
+ end
167
+
168
+ def call(result) = type.call(result)
169
+ end
170
+
171
+ def as_node(node_name, metadata = BLANK_HASH)
172
+ Node.new(node_name, self, metadata)
173
+ end
174
+
175
+ def nullable
176
+ Types::Nil | self
177
+ end
178
+
179
+ def present
180
+ Types::Present >> self
181
+ end
182
+
183
+ def options(opts = [])
184
+ rule(included_in: opts)
185
+ end
186
+
187
+ def rule(*args)
188
+ specs = case args
189
+ in [::Symbol => rule_name, value]
190
+ { rule_name => value }
191
+ in [::Hash => rules]
192
+ rules
193
+ else
194
+ raise ArgumentError, "expected 1 or 2 arguments, but got #{args.size}"
195
+ end
196
+
197
+ self >> Rules.new(specs, metadata[:type])
198
+ end
199
+
200
+ def is_a(klass)
201
+ rule(is_a: klass)
202
+ end
203
+
204
+ def ===(other)
205
+ case other
206
+ when Steppable
207
+ other == self
208
+ else
209
+ resolve(other).valid?
210
+ end
211
+ end
212
+
213
+ def coerce(type, coercion = nil, &block)
214
+ coercion ||= block
215
+ step = lambda { |result|
216
+ if type === result.value
217
+ result.valid(coercion.call(result.value))
218
+ else
219
+ result.invalid(errors: "%s can't be coerced" % result.value.inspect)
220
+ end
221
+ }
222
+ self >> step
223
+ end
224
+
225
+ def build(cns, factory_method = :new, &block)
226
+ self >> Build.new(cns, factory_method:, &block)
227
+ end
228
+
229
+ def pipeline(&block)
230
+ Pipeline.new(self, &block)
231
+ end
232
+
233
+ def to_s
234
+ inspect
235
+ end
236
+ end
237
+ end
238
+
239
+ require 'plumb/deferred'
240
+ require 'plumb/transform'
241
+ require 'plumb/build'
242
+ require 'plumb/metadata'
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class TaggedHash
7
+ include Steppable
8
+
9
+ attr_reader :key, :types
10
+
11
+ def initialize(hash_type, key, types)
12
+ @hash_type = hash_type
13
+ @key = Key.wrap(key)
14
+ @types = types
15
+
16
+ raise ArgumentError, 'all types must be HashClass' if @types.size == 0 || @types.any? do |t|
17
+ !t.is_a?(HashClass)
18
+ end
19
+ raise ArgumentError, "all types must define key #{@key}" unless @types.all? { |t| !!t.at_key(@key) }
20
+
21
+ # types are assumed to have static values for the index field :key
22
+ @index = @types.each.with_object({}) do |t, memo|
23
+ memo[t.at_key(@key).resolve.value] = t
24
+ end
25
+ end
26
+
27
+ def call(result)
28
+ result = @hash_type.call(result)
29
+ return result unless result.valid?
30
+
31
+ child = @index[result.value[@key.to_sym]]
32
+ return result.invalid(errors: "expected :#{@key.to_sym} to be one of #{@index.keys.join(', ')}") unless child
33
+
34
+ child.call(result)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class Transform
7
+ include Steppable
8
+
9
+ attr_reader :target_type
10
+
11
+ def initialize(target_type, callable)
12
+ @target_type = target_type
13
+ @callable = callable
14
+ end
15
+
16
+ def call(result)
17
+ result.valid(@callable.call(result.value))
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class TupleClass
7
+ include Steppable
8
+
9
+ attr_reader :types
10
+
11
+ def initialize(*types)
12
+ @types = types.map { |t| t.is_a?(Steppable) ? t : Types::Any.value(t) }
13
+ end
14
+
15
+ def of(*types)
16
+ self.class.new(*types)
17
+ end
18
+
19
+ alias [] of
20
+
21
+ private def _inspect
22
+ "#{name}[#{@types.map(&:inspect).join(', ')}]"
23
+ end
24
+
25
+ def call(result)
26
+ return result.invalid(errors: 'must be an Array') unless result.value.is_a?(::Array)
27
+ return result.invalid(errors: 'must have the same size') unless result.value.size == @types.size
28
+
29
+ errors = {}
30
+ values = @types.map.with_index do |type, idx|
31
+ val = result.value[idx]
32
+ r = type.resolve(val)
33
+ errors[idx] = ["expected #{type.inspect}, got #{val.inspect}", r.errors].flatten unless r.valid?
34
+ r.value
35
+ end
36
+
37
+ return result.valid(values) unless errors.any?
38
+
39
+ result.invalid(errors:)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ module TypeRegistry
5
+ def const_added(const_name)
6
+ obj = const_get(const_name)
7
+ case obj
8
+ when Module
9
+ obj.extend TypeRegistry
10
+ when Steppable
11
+ anc = [name, const_name].join('::')
12
+ obj.freeze.name.set(anc)
13
+ end
14
+ end
15
+
16
+ def included(host)
17
+ host.extend TypeRegistry
18
+ constants(false).each do |const_name|
19
+ const = const_get(const_name)
20
+ anc = [host.name, const_name].join('::')
21
+ case const
22
+ when Module
23
+ child_mod = Module.new
24
+ child_mod.define_singleton_method(:name) do
25
+ anc
26
+ end
27
+ child_mod.send(:include, const)
28
+ host.const_set(const_name, child_mod)
29
+ when Steppable
30
+ type = const.dup
31
+ type.freeze.name.set(anc)
32
+ host.const_set(const_name, type)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module Plumb
6
+ Rules.define :eq, 'must be equal to %<value>s' do |result, value|
7
+ value == result.value
8
+ end
9
+ Rules.define :not_eq, 'must not be equal to %<value>s' do |result, value|
10
+ value != result.value
11
+ end
12
+ # :gt for numbers and #size (arrays, strings, hashes)
13
+ [::String, ::Array, ::Hash].each do |klass|
14
+ Rules.define :gt, 'must contain more than %<value>s elements', expects: klass do |result, value|
15
+ value < result.value.size
16
+ end
17
+
18
+ # :lt for numbers and #size (arrays, strings, hashes)
19
+ Rules.define :lt, 'must contain fewer than %<value>s elements', expects: klass do |result, value|
20
+ value > result.value.size
21
+ end
22
+
23
+ Rules.define :gte, 'must be size greater or equal to %<value>s', expects: klass do |result, value|
24
+ value <= result.value.size
25
+ end
26
+
27
+ Rules.define :lte, 'must be size less or equal to %<value>s', expects: klass do |result, value|
28
+ value >= result.value
29
+ end
30
+ end
31
+ # :gt and :lt for numbers, BigDecimal
32
+ [::Numeric].each do |klass|
33
+ Rules.define :gt, 'must be greater than %<value>s', expects: klass do |result, value|
34
+ value < result.value
35
+ end
36
+ Rules.define :lt, 'must be greater than %<value>s', expects: klass do |result, value|
37
+ value > result.value
38
+ end
39
+ Rules.define :gte, 'must be greater or equal to %<value>s', expects: klass do |result, value|
40
+ value <= result.value
41
+ end
42
+ # :lte for numbers and #size (arrays, strings, hashes)
43
+ Rules.define :lte, 'must be less or equal to %<value>s', expects: klass do |result, value|
44
+ value >= result.value
45
+ end
46
+ end
47
+
48
+ Rules.define :match, 'must match %<value>s', metadata_key: :pattern do |result, value|
49
+ value === result.value
50
+ end
51
+ Rules.define :included_in, 'elements must be included in %<value>s', expects: ::Array,
52
+ metadata_key: :options do |result, opts|
53
+ result.value.all? { |v| opts.include?(v) }
54
+ end
55
+ Rules.define :included_in, 'must be included in %<value>s', metadata_key: :options do |result, opts|
56
+ opts.include? result.value
57
+ end
58
+ Rules.define :excluded_from, 'elements must not be included in %<value>s', expects: ::Array do |result, value|
59
+ result.value.all? { |v| !value.include?(v) }
60
+ end
61
+ Rules.define :excluded_from, 'must not be included in %<value>s' do |result, value|
62
+ !value.include?(result.value)
63
+ end
64
+ Rules.define :respond_to, 'must respond to %<value>s' do |result, value|
65
+ Array(value).all? { |m| result.value.respond_to?(m) }
66
+ end
67
+ Rules.define :size, 'must be of size %<value>s', expects: :size, metadata_key: :size do |result, value|
68
+ value === result.value.size
69
+ end
70
+
71
+ module Types
72
+ extend TypeRegistry
73
+
74
+ Any = AnyClass.new
75
+ Undefined = Any.value(Plumb::Undefined)
76
+ String = Any[::String]
77
+ Symbol = Any[::Symbol]
78
+ Numeric = Any[::Numeric]
79
+ Integer = Any[::Integer]
80
+ Decimal = Any[BigDecimal]
81
+ Static = StaticClass.new
82
+ Value = ValueClass.new
83
+ Nil = Any[::NilClass]
84
+ True = Any[::TrueClass]
85
+ False = Any[::FalseClass]
86
+ Boolean = (True | False).as_node(:boolean)
87
+ Array = ArrayClass.new
88
+ Tuple = TupleClass.new
89
+ Hash = HashClass.new
90
+ Interface = InterfaceClass.new
91
+ # TODO: type-speficic concept of blank, via Rules
92
+ Blank = (
93
+ Undefined \
94
+ | Nil \
95
+ | String.value(BLANK_STRING) \
96
+ | Hash.value(BLANK_HASH) \
97
+ | Array.value(BLANK_ARRAY)
98
+ )
99
+
100
+ Present = Blank.invalid(errors: 'must be present')
101
+ Split = String.transform(::String) { |v| v.split(/\s*,\s*/) }
102
+
103
+ module Lax
104
+ NUMBER_EXPR = /^\d{1,3}(?:,\d{3})*(?:\.\d+)?$/
105
+
106
+ String = Types::String \
107
+ | Any.coerce(BigDecimal) { |v| v.to_s('F') } \
108
+ | Any.coerce(::Numeric, &:to_s)
109
+
110
+ Symbol = Types::Symbol | Types::String.transform(::Symbol, &:to_sym)
111
+
112
+ NumberString = Types::String.match(NUMBER_EXPR)
113
+ CoercibleNumberString = NumberString.transform(::String) { |v| v.tr(',', '') }
114
+
115
+ Numeric = Types::Numeric | CoercibleNumberString.transform(::Numeric, &:to_f)
116
+
117
+ Decimal = Types::Decimal | \
118
+ (Types::Numeric.transform(::String, &:to_s) | CoercibleNumberString) \
119
+ .transform(::BigDecimal) { |v| BigDecimal(v) }
120
+
121
+ Integer = Numeric.transform(::Integer, &:to_i)
122
+ end
123
+
124
+ module Forms
125
+ True = Types::True \
126
+ | Types::String >> Any.coerce(/^true$/i) { |_| true } \
127
+ | Any.coerce('1') { |_| true } \
128
+ | Any.coerce(1) { |_| true }
129
+
130
+ False = Types::False \
131
+ | Types::String >> Any.coerce(/^false$/i) { |_| false } \
132
+ | Any.coerce('0') { |_| false } \
133
+ | Any.coerce(0) { |_| false }
134
+
135
+ Boolean = True | False
136
+
137
+ Nil = Nil | (String[BLANK_STRING] >> nil)
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class ValueClass
7
+ include Steppable
8
+
9
+ attr_reader :value
10
+
11
+ def initialize(value = Undefined)
12
+ @value = value
13
+ end
14
+
15
+ def inspect = @value.inspect
16
+
17
+ def [](value) = self.class.new(value)
18
+
19
+ def call(result)
20
+ @value == result.value ? result : result.invalid(errors: "Must be equal to #{@value}")
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ VERSION = '0.0.1'
5
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ module VisitorHandlers
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def on(node_name, &block)
11
+ name = node_name.is_a?(Symbol) ? node_name : :"#{node_name}_class"
12
+ self.define_method("visit_#{name}", &block)
13
+ end
14
+
15
+ def visit(type, props = BLANK_HASH)
16
+ new.visit(type, props)
17
+ end
18
+ end
19
+
20
+ def visit(type, props = BLANK_HASH)
21
+ method_name = type.respond_to?(:node_name) ? type.node_name : :"#{(type.is_a?(::Class) ? type : type.class)}_class"
22
+ method_name = "visit_#{method_name}"
23
+ if respond_to?(method_name)
24
+ send(method_name, type, props)
25
+ else
26
+ on_missing_handler(type, props, method_name)
27
+ end
28
+ end
29
+
30
+ def on_missing_handler(type, _props, method_name)
31
+ raise "No handler for #{type.inspect} with :#{method_name}"
32
+ end
33
+ end
34
+ end
data/lib/plumb.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ end
5
+
6
+ require 'plumb/result'
7
+ require 'plumb/type_registry'
8
+ require 'plumb/steppable'
9
+ require 'plumb/any_class'
10
+ require 'plumb/step'
11
+ require 'plumb/and'
12
+ require 'plumb/pipeline'
13
+ require 'plumb/rules'
14
+ require 'plumb/static_class'
15
+ require 'plumb/value_class'
16
+ require 'plumb/match_class'
17
+ require 'plumb/not'
18
+ require 'plumb/or'
19
+ require 'plumb/tuple_class'
20
+ require 'plumb/array_class'
21
+ require 'plumb/hash_class'
22
+ require 'plumb/interface_class'
23
+ require 'plumb/types'
24
+ require 'plumb/json_schema_visitor'
25
+ require 'plumb/schema'