plumb 0.0.1 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/lib/plumb/types.rb CHANGED
@@ -3,71 +3,127 @@
3
3
  require 'bigdecimal'
4
4
 
5
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
6
+ # Define core policies
7
+ # Allowed options for an array type.
8
+ # It validates that each element is in the options array.
9
+ # Usage:
10
+ # type = Types::Array.options(['a', 'b'])
11
+ policy :options, helper: true, for_type: ::Array do |type, opts|
12
+ type.check("must be included in #{opts.inspect}") do |v|
13
+ v.all? { |val| opts.include?(val) }
21
14
  end
15
+ end
22
16
 
23
- Rules.define :gte, 'must be size greater or equal to %<value>s', expects: klass do |result, value|
24
- value <= result.value.size
17
+ # Generic options policy for all other types.
18
+ # Usage:
19
+ # type = Types::String.options(['a', 'b'])
20
+ policy :options do |type, opts|
21
+ type.check("must be included in #{opts.inspect}") do |v|
22
+ opts.include?(v)
25
23
  end
24
+ end
26
25
 
27
- Rules.define :lte, 'must be size less or equal to %<value>s', expects: klass do |result, value|
28
- value >= result.value
26
+ # Validate that array elements are NOT in the options array.
27
+ # Usage:
28
+ # type = Types::Array.policy(excluded_from: ['a', 'b'])
29
+ policy :excluded_from, for_type: ::Array do |type, opts|
30
+ type.check("must not be included in #{opts.inspect}") do |v|
31
+ v.none? { |val| opts.include?(val) }
29
32
  end
30
33
  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
34
+
35
+ # Usage:
36
+ # type = Types::String.policy(excluded_from: ['a', 'b'])
37
+ policy :excluded_from do |type, opts|
38
+ type.check("must not be included in #{opts.inspect}") do |v|
39
+ !opts.include?(v)
45
40
  end
46
41
  end
47
42
 
48
- Rules.define :match, 'must match %<value>s', metadata_key: :pattern do |result, value|
49
- value === result.value
43
+ # Validate #size against a number or any object that responds to #===.
44
+ # This works with any type that repsonds to #size.
45
+ # Usage:
46
+ # type = Types::String.policy(size: 10)
47
+ # type = Types::Integer.policy(size: 1..10)
48
+ # type = Types::Array.policy(size: 1..)
49
+ # type = Types::Any[Set].policy(size: 1..)
50
+ policy :size, for_type: :size do |type, size|
51
+ type.check("must be of size #{size}") { |v| size === v.size }
50
52
  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) }
53
+
54
+ # Validate that an object is not #empty? nor #nil?
55
+ # Usage:
56
+ # Types::String.present
57
+ # Types::Array.present
58
+ policy :present, helper: true do |type, *_args|
59
+ type.check('must be present') do |v|
60
+ if v.respond_to?(:empty?)
61
+ !v.empty?
62
+ else
63
+ !v.nil?
64
+ end
54
65
  end
55
- Rules.define :included_in, 'must be included in %<value>s', metadata_key: :options do |result, opts|
56
- opts.include? result.value
57
66
  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) }
67
+
68
+ # Allow nil values for a type.
69
+ # Usage:
70
+ # nullable_int = Types::Integer.nullable
71
+ # nullable_int.parse(nil) # => nil
72
+ # nullable_int.parse(10) # => 10
73
+ # nullable_int.parse('nope') # => error: not an Integer
74
+ policy :nullable, helper: true do |type, *_args|
75
+ Types::Nil | type
60
76
  end
61
- Rules.define :excluded_from, 'must not be included in %<value>s' do |result, value|
62
- !value.include?(result.value)
77
+
78
+ # Validate that a value responds to a method
79
+ # Usage:
80
+ # type = Types::Any.policy(respond_to: :upcase)
81
+ # type = Types::Any.policy(respond_to: [:upcase, :strip])
82
+ policy :respond_to do |type, method_names|
83
+ type.check("must respond to #{method_names.inspect}") do |value|
84
+ Array(method_names).all? { |m| value.respond_to?(m) }
85
+ end
63
86
  end
64
- Rules.define :respond_to, 'must respond to %<value>s' do |result, value|
65
- Array(value).all? { |m| result.value.respond_to?(m) }
87
+
88
+ # Return a default value if the input value is Undefined (ie key not present in a Hash).
89
+ # Usage:
90
+ # type = Types::String.default('default')
91
+ # type.parse(Undefined) # => 'default'
92
+ # type.parse('yes') # => 'yes'
93
+ #
94
+ # Works with a block too:
95
+ # date = Type::Any[Date].default { Date.today }
96
+ #
97
+ policy :default, helper: true do |type, value = Undefined, &block|
98
+ val_type = if value == Undefined
99
+ Step.new(->(result) { result.valid(block.call) }, 'default proc')
100
+ else
101
+ Types::Static[value]
102
+ end
103
+
104
+ type | (Types::Undefined >> val_type)
66
105
  end
67
- Rules.define :size, 'must be of size %<value>s', expects: :size, metadata_key: :size do |result, value|
68
- value === result.value.size
106
+
107
+ # Split a string into an array. Default separator is /\s*,\s*/
108
+ # Usage:
109
+ # type = Types::String.split
110
+ # type.parse('a,b,c') # => ['a', 'b', 'c']
111
+ #
112
+ # Custom separator:
113
+ # type = Types::String.split(';')
114
+ module SplitPolicy
115
+ DEFAULT_SEPARATOR = /\s*,\s*/
116
+
117
+ def self.call(type, separator = DEFAULT_SEPARATOR)
118
+ type.invoke(:split, separator) >> Types::Array[String]
119
+ end
120
+
121
+ def self.for_type = ::String
122
+ def self.helper = false
69
123
  end
70
124
 
125
+ policy :split, SplitPolicy
126
+
71
127
  module Types
72
128
  extend TypeRegistry
73
129
 
@@ -85,27 +141,17 @@ module Plumb
85
141
  False = Any[::FalseClass]
86
142
  Boolean = (True | False).as_node(:boolean)
87
143
  Array = ArrayClass.new
144
+ Stream = StreamClass.new
88
145
  Tuple = TupleClass.new
89
146
  Hash = HashClass.new
90
147
  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
148
 
103
149
  module Lax
104
150
  NUMBER_EXPR = /^\d{1,3}(?:,\d{3})*(?:\.\d+)?$/
105
151
 
106
152
  String = Types::String \
107
- | Any.coerce(BigDecimal) { |v| v.to_s('F') } \
108
- | Any.coerce(::Numeric, &:to_s)
153
+ | Types::Decimal.transform(::String) { |v| v.to_s('F') } \
154
+ | Types::Numeric.transform(::String, &:to_s)
109
155
 
110
156
  Symbol = Types::Symbol | Types::String.transform(::Symbol, &:to_sym)
111
157
 
@@ -115,22 +161,26 @@ module Plumb
115
161
  Numeric = Types::Numeric | CoercibleNumberString.transform(::Numeric, &:to_f)
116
162
 
117
163
  Decimal = Types::Decimal | \
118
- (Types::Numeric.transform(::String, &:to_s) | CoercibleNumberString) \
119
- .transform(::BigDecimal) { |v| BigDecimal(v) }
164
+ (Types::Numeric.transform(::String, &:to_s) | CoercibleNumberString) \
165
+ .transform(::BigDecimal) { |v| BigDecimal(v) }
120
166
 
121
167
  Integer = Numeric.transform(::Integer, &:to_i)
122
168
  end
123
169
 
124
170
  module Forms
125
171
  True = Types::True \
126
- | Types::String >> Any.coerce(/^true$/i) { |_| true } \
127
- | Any.coerce('1') { |_| true } \
128
- | Any.coerce(1) { |_| true }
172
+ | (
173
+ Types::String[/^true$/i] \
174
+ | Types::String['1'] \
175
+ | Types::Integer[1]
176
+ ).transform(::TrueClass) { |_| true }
129
177
 
130
178
  False = Types::False \
131
- | Types::String >> Any.coerce(/^false$/i) { |_| false } \
132
- | Any.coerce('0') { |_| false } \
133
- | Any.coerce(0) { |_| false }
179
+ | (
180
+ Types::String[/^false$/i] \
181
+ | Types::String['0'] \
182
+ | Types::Integer[0]
183
+ ).transform(::FalseClass) { |_| false }
134
184
 
135
185
  Boolean = True | False
136
186
 
@@ -10,14 +10,17 @@ module Plumb
10
10
 
11
11
  def initialize(value = Undefined)
12
12
  @value = value
13
+ freeze
13
14
  end
14
15
 
15
- def inspect = @value.inspect
16
-
17
16
  def [](value) = self.class.new(value)
18
17
 
19
18
  def call(result)
20
19
  @value == result.value ? result : result.invalid(errors: "Must be equal to #{@value}")
21
20
  end
21
+
22
+ private
23
+
24
+ def _inspect = @value.inspect
22
25
  end
23
26
  end
data/lib/plumb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plumb
4
- VERSION = '0.0.1'
4
+ VERSION = '0.0.3'
5
5
  end
@@ -9,26 +9,35 @@ module Plumb
9
9
  module ClassMethods
10
10
  def on(node_name, &block)
11
11
  name = node_name.is_a?(Symbol) ? node_name : :"#{node_name}_class"
12
- self.define_method("visit_#{name}", &block)
12
+ define_method("visit_#{name}", &block)
13
13
  end
14
14
 
15
- def visit(type, props = BLANK_HASH)
16
- new.visit(type, props)
15
+ def visit(node, props = BLANK_HASH)
16
+ new.visit(node, props)
17
17
  end
18
18
  end
19
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}"
20
+ def visit(node, props = BLANK_HASH)
21
+ method_name = if node.respond_to?(:node_name)
22
+ node.node_name
23
+ else
24
+ :"#{(node.is_a?(::Class) ? node : node.class)}_class"
25
+ end
26
+
27
+ visit_name(method_name, node, props)
28
+ end
29
+
30
+ def visit_name(method_name, node, props = BLANK_HASH)
31
+ method_name = :"visit_#{method_name}"
23
32
  if respond_to?(method_name)
24
- send(method_name, type, props)
33
+ send(method_name, node, props)
25
34
  else
26
- on_missing_handler(type, props, method_name)
35
+ on_missing_handler(node, props, method_name)
27
36
  end
28
37
  end
29
38
 
30
- def on_missing_handler(type, _props, method_name)
31
- raise "No handler for #{type.inspect} with :#{method_name}"
39
+ def on_missing_handler(node, _props, method_name)
40
+ raise "No handler for #{node.inspect} with :#{method_name}"
32
41
  end
33
42
  end
34
43
  end
data/lib/plumb.rb CHANGED
@@ -1,6 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'plumb/policies'
4
+
3
5
  module Plumb
6
+ @policies = Policies.new
7
+
8
+ def self.policies
9
+ @policies
10
+ end
11
+
12
+ # Register a policy with the given name and block.
13
+ # Optionally define a method on the Steppable method to call the policy.
14
+ # Example:
15
+ # Plumb.policy(:multiply_by, for_type: Integer, helper: true) do |step, factor, &block|
16
+ # step.transform(Integer) { |number| number * factor }
17
+ # end
18
+ #
19
+ # type = Types::Integer.multiply_by(2)
20
+ # type.parse(10) # => 20
21
+ #
22
+ # @param name [Symbol] the name of the policy
23
+ # @param opts [Hash] options for the policy
24
+ # @yield [Step, Object, &block] the step (type), policy argument, and policy block, if any.
25
+ def self.policy(name, opts = {}, &block)
26
+ name = name.to_sym
27
+ if opts.is_a?(Hash) && block_given?
28
+ for_type = opts[:for_type] || Object
29
+ helper = opts[:helper] || false
30
+ elsif opts.respond_to?(:call) && opts.respond_to?(:for_type) && opts.respond_to?(:helper)
31
+ for_type = opts.for_type
32
+ helper = opts.helper
33
+ block = opts.method(:call)
34
+ else
35
+ raise ArgumentError, 'Expected a block or a hash with :for_type and :helper keys'
36
+ end
37
+
38
+ policies.register(for_type, name, block)
39
+
40
+ return self unless helper
41
+
42
+ if Steppable.instance_methods.include?(name)
43
+ raise Policies::MethodAlreadyDefinedError, "Method #{name} is already defined on Steppable"
44
+ end
45
+
46
+ Steppable.define_method(name) do |arg = Undefined, &bl|
47
+ if arg == Undefined
48
+ policy(name, &bl)
49
+ else
50
+ policy(name, arg, &bl)
51
+ end
52
+ end
53
+
54
+ self
55
+ end
4
56
  end
5
57
 
6
58
  require 'plumb/result'
@@ -10,7 +62,6 @@ require 'plumb/any_class'
10
62
  require 'plumb/step'
11
63
  require 'plumb/and'
12
64
  require 'plumb/pipeline'
13
- require 'plumb/rules'
14
65
  require 'plumb/static_class'
15
66
  require 'plumb/value_class'
16
67
  require 'plumb/match_class'
@@ -18,6 +69,7 @@ require 'plumb/not'
18
69
  require 'plumb/or'
19
70
  require 'plumb/tuple_class'
20
71
  require 'plumb/array_class'
72
+ require 'plumb/stream_class'
21
73
  require 'plumb/hash_class'
22
74
  require 'plumb/interface_class'
23
75
  require 'plumb/types'
metadata CHANGED
@@ -1,17 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plumb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-07-01 00:00:00.000000000 Z
11
+ date: 2024-07-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: concurrent-ruby
14
+ name: bigdecimal
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
@@ -25,7 +25,7 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
27
  - !ruby/object:Gem::Dependency
28
- name: bigdecimal
28
+ name: concurrent-ruby
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
@@ -50,6 +50,12 @@ files:
50
50
  - LICENSE.txt
51
51
  - README.md
52
52
  - Rakefile
53
+ - examples/command_objects.rb
54
+ - examples/concurrent_downloads.rb
55
+ - examples/csv_stream.rb
56
+ - examples/env_config.rb
57
+ - examples/programmers.csv
58
+ - examples/weekdays.rb
53
59
  - lib/plumb.rb
54
60
  - lib/plumb/and.rb
55
61
  - lib/plumb/any_class.rb
@@ -67,12 +73,14 @@ files:
67
73
  - lib/plumb/not.rb
68
74
  - lib/plumb/or.rb
69
75
  - lib/plumb/pipeline.rb
76
+ - lib/plumb/policies.rb
77
+ - lib/plumb/policy.rb
70
78
  - lib/plumb/result.rb
71
- - lib/plumb/rules.rb
72
79
  - lib/plumb/schema.rb
73
80
  - lib/plumb/static_class.rb
74
81
  - lib/plumb/step.rb
75
82
  - lib/plumb/steppable.rb
83
+ - lib/plumb/stream_class.rb
76
84
  - lib/plumb/tagged_hash.rb
77
85
  - lib/plumb/transform.rb
78
86
  - lib/plumb/tuple_class.rb
@@ -81,7 +89,7 @@ files:
81
89
  - lib/plumb/value_class.rb
82
90
  - lib/plumb/version.rb
83
91
  - lib/plumb/visitor_handlers.rb
84
- homepage: ''
92
+ homepage: https://github.com/ismasan/plumb
85
93
  licenses:
86
94
  - MIT
87
95
  metadata: {}
data/lib/plumb/rules.rb DELETED
@@ -1,103 +0,0 @@
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