plumb 0.0.1

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.
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/visitor_handlers'
4
+
5
+ module Plumb
6
+ class MetadataVisitor
7
+ include VisitorHandlers
8
+
9
+ def self.call(type)
10
+ new.visit(type)
11
+ end
12
+
13
+ def on_missing_handler(type, props, method_name)
14
+ return props.merge(type: type) if type.class == Class
15
+
16
+ puts "Missing handler for #{type.inspect} with props #{props.inspect} and method_name :#{method_name}"
17
+ props
18
+ end
19
+
20
+ on(:undefined) do |type, props|
21
+ props
22
+ end
23
+
24
+ on(:any) do |type, props|
25
+ props
26
+ end
27
+
28
+ on(:pipeline) do |type, props|
29
+ visit(type.type, props)
30
+ end
31
+
32
+ on(:step) do |type, props|
33
+ props.merge(type._metadata)
34
+ end
35
+
36
+ on(::Regexp) do |type, props|
37
+ props.merge(pattern: type)
38
+ end
39
+
40
+ on(::Range) do |type, props|
41
+ props.merge(match: type)
42
+ end
43
+
44
+ on(:match) do |type, props|
45
+ visit(type.matcher, props)
46
+ end
47
+
48
+ on(:hash) do |type, props|
49
+ props.merge(type: Hash)
50
+ end
51
+
52
+ on(:and) do |type, props|
53
+ left = visit(type.left)
54
+ right = visit(type.right)
55
+ type = right[:type] || left[:type]
56
+ props = props.merge(left).merge(right)
57
+ props = props.merge(type:) if type
58
+ props
59
+ end
60
+
61
+ on(:or) do |type, props|
62
+ child_metas = [visit(type.left), visit(type.right)]
63
+ types = child_metas.map { |child| child[:type] }.flatten.compact
64
+ types = types.first if types.size == 1
65
+ child_metas.reduce(props) do |acc, child|
66
+ acc.merge(child)
67
+ end.merge(type: types)
68
+ end
69
+
70
+ on(:value) do |type, props|
71
+ visit(type.value, props)
72
+ end
73
+
74
+ on(:transform) do |type, props|
75
+ props.merge(type: type.target_type)
76
+ end
77
+
78
+ on(:static) do |type, props|
79
+ props.merge(static: type.value)
80
+ end
81
+
82
+ on(:rules) do |type, props|
83
+ type.rules.reduce(props) do |acc, rule|
84
+ acc.merge(rule.name => rule.arg_value)
85
+ end
86
+ end
87
+
88
+ on(:boolean) do |type, props|
89
+ props.merge(type: 'boolean')
90
+ end
91
+
92
+ on(:metadata) do |type, props|
93
+ props.merge(type.metadata)
94
+ end
95
+
96
+ on(:hash_map) do |type, props|
97
+ props.merge(type: Hash)
98
+ end
99
+
100
+ on(:build) do |type, props|
101
+ visit(type.type, props)
102
+ end
103
+
104
+ on(:array) do |type, props|
105
+ props.merge(type: Array)
106
+ end
107
+
108
+ on(:tuple) do |type, props|
109
+ props.merge(type: Array)
110
+ end
111
+
112
+ on(:tagged_hash) do |type, props|
113
+ props.merge(type: Hash)
114
+ end
115
+ end
116
+ end
data/lib/plumb/not.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class Not
7
+ include Steppable
8
+
9
+ attr_reader :step
10
+
11
+ def initialize(step, errors: nil)
12
+ @step = step
13
+ @errors = errors
14
+ freeze
15
+ end
16
+
17
+ private def _inspect
18
+ %(Not(#{@step.inspect}))
19
+ end
20
+
21
+ def call(result)
22
+ result = @step.call(result)
23
+ result.valid? ? result.invalid(errors: @errors) : result.valid
24
+ end
25
+ end
26
+ end
data/lib/plumb/or.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class Or
7
+ include Steppable
8
+
9
+ attr_reader :left, :right
10
+
11
+ def initialize(left, right)
12
+ @left = left
13
+ @right = right
14
+ freeze
15
+ end
16
+
17
+ private def _inspect
18
+ %((#{@left.inspect} | #{@right.inspect}))
19
+ end
20
+
21
+ def call(result)
22
+ left_result = @left.call(result)
23
+ return left_result if left_result.valid?
24
+
25
+ right_result = @right.call(result)
26
+ right_result.valid? ? right_result : result.invalid(errors: [left_result.errors, right_result.errors].flatten)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class Pipeline
7
+ include Steppable
8
+
9
+ class AroundStep
10
+ include Steppable
11
+
12
+ def initialize(step, block)
13
+ @step = step
14
+ @block = block
15
+ end
16
+
17
+ def call(result)
18
+ @block.call(@step, result)
19
+ end
20
+ end
21
+
22
+ attr_reader :type
23
+
24
+ def initialize(type = Types::Any, &setup)
25
+ @type = type
26
+ @around_blocks = []
27
+ return unless block_given?
28
+
29
+ configure(&setup)
30
+ freeze
31
+ end
32
+
33
+ def call(result)
34
+ @type.call(result)
35
+ end
36
+
37
+ def step(callable = nil, &block)
38
+ callable ||= block
39
+ unless is_a_step?(callable)
40
+ raise ArgumentError,
41
+ "#step expects an interface #call(Result) Result, but got #{callable.inspect}"
42
+ end
43
+
44
+ callable = @around_blocks.reduce(callable) { |cl, bl| AroundStep.new(cl, bl) } if @around_blocks.any?
45
+ @type >>= callable
46
+ self
47
+ end
48
+
49
+ def around(callable = nil, &block)
50
+ @around_blocks << (callable || block)
51
+ self
52
+ end
53
+
54
+ private
55
+
56
+ def configure(&setup)
57
+ case setup.arity
58
+ when 1
59
+ setup.call(self)
60
+ when 0
61
+ instance_eval(&setup)
62
+ else
63
+ raise ArgumentError, 'setup block must have arity of 0 or 1'
64
+ end
65
+ end
66
+
67
+ def is_a_step?(callable)
68
+ return false unless callable.respond_to?(:call)
69
+
70
+ true
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ class Result
5
+ class << self
6
+ def valid(value)
7
+ Valid.new(value)
8
+ end
9
+
10
+ def invalid(value = nil, errors: nil)
11
+ Invalid.new(value, errors:)
12
+ end
13
+
14
+ def wrap(value)
15
+ return value if value.is_a?(Result)
16
+
17
+ valid(value)
18
+ end
19
+ end
20
+
21
+ attr_reader :value, :errors
22
+
23
+ def initialize(value, errors: nil)
24
+ @value = value
25
+ @errors = errors
26
+ end
27
+
28
+ def valid? = true
29
+ def invalid? = false
30
+
31
+ def inspect
32
+ %(<#{self.class}##{object_id} value:#{value.inspect} errors:#{errors.inspect}>)
33
+ end
34
+
35
+ def reset(val)
36
+ @value = val
37
+ @errors = nil
38
+ self
39
+ end
40
+
41
+ def valid(val = value)
42
+ Result.valid(val)
43
+ end
44
+
45
+ def invalid(val = value, errors: nil)
46
+ Result.invalid(val, errors:)
47
+ end
48
+
49
+ class Valid < self
50
+ def map(callable)
51
+ callable.call(self)
52
+ end
53
+ end
54
+
55
+ class Invalid < self
56
+ def valid? = false
57
+ def invalid? = true
58
+
59
+ def map(_)
60
+ self
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class Rules
7
+ UnsupportedRuleError = Class.new(StandardError)
8
+ UndefinedRuleError = Class.new(KeyError)
9
+
10
+ class Registry
11
+ RuleDef = Data.define(:name, :error_tpl, :callable, :metadata_key, :expects) do
12
+ def supports?(type)
13
+ types = [type].flatten # may be an array of types for OR logic
14
+ case expects
15
+ when Symbol
16
+ types.all? { |type| type.public_instance_methods.include?(expects) }
17
+ when Class then types.all? { |type| type <= expects }
18
+ when Object then true
19
+ else raise "Unexpected expects: #{expects}"
20
+ end
21
+ end
22
+ end
23
+
24
+ Rule = Data.define(:rule_def, :arg_value, :error_str) do
25
+ def self.build(rule_def, arg_value)
26
+ error_str = format(rule_def.error_tpl, value: arg_value)
27
+ new(rule_def, arg_value, error_str)
28
+ end
29
+
30
+ def node_name = :"rule_#{rule_def.name}"
31
+ def name = rule_def.name
32
+ def metadata_key = rule_def.metadata_key
33
+
34
+ def error_for(result)
35
+ return nil if rule_def.callable.call(result, arg_value)
36
+
37
+ error_str
38
+ end
39
+ end
40
+
41
+ def initialize
42
+ @definitions = Hash.new { |h, k| h[k] = Set.new }
43
+ end
44
+
45
+ def define(name, error_tpl, callable = nil, metadata_key: name, expects: Object, &block)
46
+ name = name.to_sym
47
+ callable ||= block
48
+ @definitions[name] << RuleDef.new(name:, error_tpl:, callable:, metadata_key:, expects:)
49
+ end
50
+
51
+ # Ex. size: 3, match: /foo/
52
+ def resolve(rule_specs, for_type)
53
+ rule_specs.map do |(name, arg_value)|
54
+ rule_defs = @definitions.fetch(name.to_sym) { raise UndefinedRuleError, "no rule defined with :#{name}" }
55
+ rule_def = rule_defs.find { |rd| rd.supports?(for_type) }
56
+ unless rule_def
57
+ raise UnsupportedRuleError, "No :#{name} rule for type #{for_type}" unless for_type.is_a?(Array)
58
+
59
+ raise UnsupportedRuleError,
60
+ "Can't apply :#{name} rule for types #{for_type}. All types must support the same rule implementation"
61
+
62
+ end
63
+
64
+ Rule.build(rule_def, arg_value)
65
+ end
66
+ end
67
+ end
68
+
69
+ include Steppable
70
+
71
+ def self.registry
72
+ @registry ||= Registry.new
73
+ end
74
+
75
+ def self.define(...)
76
+ registry.define(...)
77
+ end
78
+
79
+ # Ex. new(size: 3, match: /foo/)
80
+ attr_reader :rules
81
+
82
+ def initialize(rule_specs, for_type)
83
+ @rules = self.class.registry.resolve(rule_specs, for_type).freeze
84
+ freeze
85
+ end
86
+
87
+ def call(result)
88
+ errors = []
89
+ err = nil
90
+ @rules.each do |rule|
91
+ err = rule.error_for(result)
92
+ errors << err if err
93
+ end
94
+ return result unless errors.any?
95
+
96
+ result.invalid(errors: errors.join(', '))
97
+ end
98
+
99
+ private def _inspect
100
+ +'Rules(' << @rules.map { |r| [r.name, r.arg_value].join(': ') }.join(', ') << +')'
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'plumb/json_schema_visitor'
5
+
6
+ module Plumb
7
+ class Schema
8
+ include Steppable
9
+
10
+ def self.wrap(sc = nil, &block)
11
+ raise ArgumentError, 'expected a block or a schema' if sc.nil? && !block_given?
12
+
13
+ if sc
14
+ raise ArgumentError, 'expected a Steppable' unless sc.is_a?(Steppable)
15
+
16
+ return sc
17
+ end
18
+
19
+ new(&block)
20
+ end
21
+
22
+ attr_reader :fields
23
+
24
+ def initialize(hash = Types::Hash, &block)
25
+ @pipeline = Types::Any
26
+ @before = Types::Any
27
+ @after = Types::Any
28
+ @_schema = {}
29
+ @_hash = hash
30
+ @fields = SymbolAccessHash.new({})
31
+
32
+ setup(&block) if block_given?
33
+
34
+ finish
35
+ end
36
+
37
+ def inspect
38
+ "#{self.class}#{fields.keys.inspect}"
39
+ end
40
+
41
+ def before(callable = nil, &block)
42
+ @before >>= callable || block
43
+ self
44
+ end
45
+
46
+ def after(callable = nil, &block)
47
+ @after >>= callable || block
48
+ self
49
+ end
50
+
51
+ def json_schema
52
+ JSONSchemaVisitor.call(_hash).to_h
53
+ end
54
+
55
+ def call(result)
56
+ @pipeline.call(result)
57
+ end
58
+
59
+ private def setup(&block)
60
+ case block.arity
61
+ when 1
62
+ yield self
63
+ when 0
64
+ instance_eval(&block)
65
+ else
66
+ raise ::ArgumentError, "#{self.class} expects a block with 0 or 1 argument, but got #{block.arity}"
67
+ end
68
+ @_hash = Types::Hash.schema(@fields.transform_values(&:_type))
69
+ self
70
+ end
71
+
72
+ private def finish
73
+ @pipeline = @before.freeze >> @_hash.freeze >> @after.freeze
74
+ @_schema.clear.freeze
75
+ freeze
76
+ end
77
+
78
+ def field(key)
79
+ key = Key.new(key.to_sym)
80
+ @fields[key] = Field.new(key)
81
+ end
82
+
83
+ def field?(key)
84
+ key = Key.new(key.to_sym, optional: true)
85
+ @fields[key] = Field.new(key)
86
+ end
87
+
88
+ def +(other)
89
+ self.class.new(_hash + other._hash)
90
+ end
91
+
92
+ def &(other)
93
+ self.class.new(_hash & other._hash)
94
+ end
95
+
96
+ def merge(other = nil, &block)
97
+ other = self.class.wrap(other, &block)
98
+ self + other
99
+ end
100
+
101
+ protected
102
+
103
+ attr_reader :_hash
104
+
105
+ private
106
+
107
+ attr_reader :_schema
108
+
109
+ class SymbolAccessHash < SimpleDelegator
110
+ def [](key)
111
+ __getobj__[Key.wrap(key)]
112
+ end
113
+ end
114
+
115
+ class Field
116
+ include Callable
117
+
118
+ attr_reader :_type, :key
119
+
120
+ def initialize(key)
121
+ @key = key.to_sym
122
+ @_type = Types::Any
123
+ end
124
+
125
+ def call(result) = _type.call(result)
126
+
127
+ def type(steppable)
128
+ unless steppable.respond_to?(:call)
129
+ raise ArgumentError,
130
+ "expected a Plumb type, but got #{steppable.inspect}"
131
+ end
132
+
133
+ @_type >>= steppable
134
+ self
135
+ end
136
+
137
+ def schema(...)
138
+ @_type >>= Schema.wrap(...)
139
+ self
140
+ end
141
+
142
+ def array(...)
143
+ @_type >>= Types::Array[Schema.wrap(...)]
144
+ self
145
+ end
146
+
147
+ def default(v, &block)
148
+ @_type = @_type.default(v, &block)
149
+ self
150
+ end
151
+
152
+ def meta(md = nil)
153
+ @_type = @_type.meta(md) if md
154
+ self
155
+ end
156
+
157
+ def metadata = @_type.metadata
158
+
159
+ def options(opts)
160
+ @_type = @_type.rule(included_in: opts)
161
+ self
162
+ end
163
+
164
+ def optional
165
+ @_type = Types::Nil | @_type
166
+ self
167
+ end
168
+
169
+ def present
170
+ @_type = @_type.present
171
+ self
172
+ end
173
+
174
+ def required
175
+ @_type = Types::Undefined.invalid(errors: 'is required') >> @_type
176
+ self
177
+ end
178
+
179
+ def rule(...)
180
+ @_type = @_type.rule(...)
181
+ self
182
+ end
183
+
184
+ def inspect
185
+ "#{self.class}[#{@_type.inspect}]"
186
+ end
187
+
188
+ private
189
+
190
+ attr_reader :registry
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class StaticClass
7
+ include Steppable
8
+
9
+ attr_reader :value
10
+
11
+ def initialize(value = Undefined)
12
+ raise ArgumentError, 'value must be frozen' unless value.frozen?
13
+
14
+ @value = value
15
+ freeze
16
+ end
17
+
18
+ def [](value)
19
+ self.class.new(value)
20
+ end
21
+
22
+ private def _inspect
23
+ %(#{name}[#{@value.inspect}])
24
+ end
25
+
26
+ def call(result)
27
+ result.valid(@value)
28
+ end
29
+ end
30
+ end
data/lib/plumb/step.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb/steppable'
4
+
5
+ module Plumb
6
+ class Step
7
+ include Steppable
8
+
9
+ attr_reader :_metadata
10
+
11
+ def initialize(callable = nil, &block)
12
+ @_metadata = callable.respond_to?(:metadata) ? callable.metadata : BLANK_HASH
13
+ @callable = callable || block
14
+ freeze
15
+ end
16
+
17
+ def call(result)
18
+ @callable.call(result)
19
+ end
20
+ end
21
+ end