plumb 0.0.1 → 0.0.3

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.
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