plumb 0.0.3 → 0.0.4

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.
@@ -15,8 +15,8 @@ module Types
15
15
  # Turn a string into an URI
16
16
  URL = String[/^https?:/].build(::URI, :parse)
17
17
 
18
- # a Struct to holw image data
19
- Image = Data.define(:url, :io)
18
+ # a Struct to hold image data
19
+ Image = ::Data.define(:url, :io)
20
20
 
21
21
  # A (naive) step to download files from the internet
22
22
  # and return an Image struct.
@@ -38,7 +38,7 @@ module Types
38
38
  # Wrap the #reader and #wruter methods into Plumb steps
39
39
  # A step only needs #call(Result) => Result to work in a pipeline,
40
40
  # but wrapping it in Plumb::Step provides the #>> and #| methods for composability,
41
- # as well as all the other helper methods provided by the Steppable module.
41
+ # as well as all the other helper methods provided by the Composable module.
42
42
  def read = Plumb::Step.new(method(:reader))
43
43
  def write = Plumb::Step.new(method(:writer))
44
44
 
@@ -32,10 +32,10 @@ module Types
32
32
  end
33
33
 
34
34
  # A dummy S3 client
35
- S3Client = Data.define(:bucket, :region)
35
+ S3Client = ::Data.define(:bucket, :region)
36
36
 
37
37
  # A dummy SFTP client
38
- SFTPClient = Data.define(:host, :username, :password)
38
+ SFTPClient = ::Data.define(:host, :username, :password)
39
39
 
40
40
  # Map these fields to an S3 client
41
41
  S3Config = Types::Hash[
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb'
4
+ require 'time'
5
+ require 'uri'
6
+ require 'securerandom'
7
+ require 'debug'
8
+
9
+ # Bring Plumb into our own namespace
10
+ # and define some basic types
11
+ module Types
12
+ include Plumb::Types
13
+
14
+ # Turn an ISO8601 sring into a Time object
15
+ ISOTime = String.build(::Time, :parse)
16
+
17
+ # A type that can be a Time object or an ISO8601 string >> Time
18
+ Time = Any[::Time] | ISOTime
19
+
20
+ # A UUID string
21
+ UUID = String[/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i]
22
+
23
+ # A UUID string, or generate a new one
24
+ AutoUUID = UUID.default { SecureRandom.uuid }
25
+
26
+ Email = String[URI::MailTo::EMAIL_REGEXP]
27
+ end
28
+
29
+ # A superclass and registry to define event types
30
+ # for example for an event-driven or event-sourced system.
31
+ # All events have an "envelope" set of attributes,
32
+ # including unique ID, stream_id, type, timestamp, causation ID,
33
+ # event subclasses have a type string (ex. 'users.name.updated') and an optional payload
34
+ # This class provides a `.define` method to create new event types with a type and optional payload struct,
35
+ # a `.from` method to instantiate the correct subclass from a hash, ex. when deserializing from JSON or a web request.
36
+ # and a `#follow` method to produce new events based on a previous event's envelope, where the #causation_id and #correlation_id
37
+ # are set to the parent event
38
+ # @example
39
+ #
40
+ # # Define event struct with type and payload
41
+ # UserCreated = Event.define('users.created') do
42
+ # attribute :name, Types::String
43
+ # attribute :email, Types::Email
44
+ # end
45
+ #
46
+ # # Instantiate a full event with .new
47
+ # user_created = UserCreated.new(stream_id: 'user-1', payload: { name: 'Joe', email: '...' })
48
+ #
49
+ # # Use the `.from(Hash) => Event` factory to lookup event class by `type` and produce the right instance
50
+ # user_created = Event.from(type: 'users.created', stream_id: 'user-1', payload: { name: 'Joe', email: '...' })
51
+ #
52
+ # # Use #follow(payload Hash) => Event to produce events following a command or parent event
53
+ # create_user = CreateUser.new(...)
54
+ # user_created = create_user.follow(UserCreated, name: 'Joe', email: '...')
55
+ # user_created.causation_id == create_user.id
56
+ # user_created.correlation_id == create_user.correlation_id
57
+ # user_created.stream_id == create_user.stream_id
58
+ #
59
+ # ## JSON Schemas
60
+ # Plumb data structs support `.to_json_schema`, to you can document all events in the registry with something like
61
+ #
62
+ # Event.registry.values.map(&:to_json_schema)
63
+ #
64
+ class Event < Types::Data
65
+ attribute :id, Types::AutoUUID
66
+ attribute :stream_id, Types::String.present
67
+ attribute :type, Types::String
68
+ attribute(:created_at, Types::Time.default { ::Time.now })
69
+ attribute? :causation_id, Types::UUID
70
+ attribute? :correlation_id, Types::UUID
71
+ attribute :payload, Types::Static[nil]
72
+
73
+ def self.registry
74
+ @registry ||= {}
75
+ end
76
+
77
+ def self.define(type_str, &payload_block)
78
+ type_str.freeze unless type_str.frozen?
79
+ registry[type_str] = Class.new(self) do
80
+ attribute :type, Types::Static[type_str]
81
+ attribute :payload, &payload_block if block_given?
82
+ end
83
+ end
84
+
85
+ def self.from(attrs)
86
+ klass = registry[attrs[:type]]
87
+ raise ArgumentError, "Unknown event type: #{attrs[:type]}" unless klass
88
+
89
+ klass.new(attrs)
90
+ end
91
+
92
+ def follow(event_class, payload_attrs = nil)
93
+ attrs = { stream_id:, causation_id: id, correlation_id: }
94
+ attrs[:payload] = payload_attrs if payload_attrs
95
+ event_class.new(attrs)
96
+ end
97
+ end
98
+
99
+ # Example command and events for a simple event-sourced system
100
+ #
101
+ # ## Commands
102
+ # CreateUser = Event.define('users.create') do
103
+ # attribute :name, Types::String.present
104
+ # attribute :email, Types::Email
105
+ # end
106
+ #
107
+ # UpdateUserName = Event.define('users.update_name') do
108
+ # attribute :name, Types::String.present
109
+ # end
110
+ #
111
+ # ## Events
112
+ # UserCreated = Event.define('users.created') do
113
+ # attribute :name, Types::String
114
+ # attribute :email, Types::Email
115
+ # end
116
+ #
117
+ # UserNameUpdated = Event.define('users.name_updated') do
118
+ # attribute :name, Types::String
119
+ # end
120
+ # debugger
data/lib/plumb/and.rb CHANGED
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'plumb/steppable'
3
+ require 'plumb/composable'
4
4
 
5
5
  module Plumb
6
6
  class And
7
- include Steppable
7
+ include Composable
8
8
 
9
- attr_reader :left, :right
9
+ attr_reader :children
10
10
 
11
11
  def initialize(left, right)
12
12
  @left = left
13
13
  @right = right
14
+ @children = [left, right].freeze
14
15
  freeze
15
16
  end
16
17
 
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'plumb/steppable'
3
+ require 'plumb/composable'
4
4
 
5
5
  module Plumb
6
6
  class AnyClass
7
- include Steppable
7
+ include Composable
8
8
 
9
- def |(other) = Steppable.wrap(other)
10
- def >>(other) = Steppable.wrap(other)
9
+ def |(other) = Composable.wrap(other)
10
+ def >>(other) = Composable.wrap(other)
11
11
 
12
12
  # Any.default(value) must trigger default when value is Undefined
13
13
  def default(...)
@@ -1,18 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'concurrent'
4
- require 'plumb/steppable'
4
+ require 'plumb/composable'
5
5
  require 'plumb/result'
6
6
  require 'plumb/stream_class'
7
7
 
8
8
  module Plumb
9
9
  class ArrayClass
10
- include Steppable
10
+ include Composable
11
11
 
12
- attr_reader :element_type
12
+ attr_reader :children
13
13
 
14
14
  def initialize(element_type: Types::Any)
15
- @element_type = Steppable.wrap(element_type)
15
+ @element_type = Composable.wrap(element_type)
16
+ @children = [@element_type].freeze
16
17
 
17
18
  freeze
18
19
  end
@@ -47,11 +48,13 @@ module Plumb
47
48
  values, errors = map_array_elements(result.value)
48
49
  return result.valid(values) unless errors.any?
49
50
 
50
- result.invalid(errors:)
51
+ result.invalid(values, errors:)
51
52
  end
52
53
 
53
54
  private
54
55
 
56
+ attr_reader :element_type
57
+
55
58
  def _inspect
56
59
  %(Array[#{element_type}])
57
60
  end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumb
4
+ module Attributes
5
+ # A module that provides a simple way to define a struct-like class with
6
+ # attributes that are type-checked on initialization.
7
+ #
8
+ # @example
9
+ # class Person
10
+ # include Plumb::Attributes
11
+ #
12
+ # attribute :name, Types::String
13
+ # attribute :age, Types::Integer[18..]
14
+ # end
15
+ #
16
+ # person = Person.new(name: 'Jane', age: 20)
17
+ # person.valid? # => true
18
+ # person.errors # => {}
19
+ # person.name # => 'Jane'
20
+ #
21
+ # It supports nested attributes:
22
+ #
23
+ # @example
24
+ # class Person
25
+ # include Plumb::Attributes
26
+ #
27
+ # attribute :friend do
28
+ # attribute :name, String
29
+ # end
30
+ # end
31
+ #
32
+ # person = Person.new(friend: { name: 'John' })
33
+ #
34
+ # Or arrays of nested attributes:
35
+ #
36
+ # @example
37
+ # class Person
38
+ # include Plumb::Attributes
39
+ #
40
+ # attribute :friends, Types::Array do
41
+ # atrribute :name, String
42
+ # end
43
+ # end
44
+ #
45
+ # person = Person.new(friends: [{ name: 'John' }])
46
+ #
47
+ # Or use struct classes defined separately:
48
+ #
49
+ # @example
50
+ # class Company
51
+ # include Plumb::Attributes
52
+ # attribute :name, String
53
+ # end
54
+ #
55
+ # class Person
56
+ # include Plumb::Attributes
57
+ #
58
+ # # Single nested struct
59
+ # attribute :company, Company
60
+ #
61
+ # # Array of nested structs
62
+ # attribute :companies, Types::Array[Company]
63
+ # end
64
+ #
65
+ # Arrays and other types support composition and helpers. Ex. `#default`.
66
+ #
67
+ # attribute :companies, Types::Array[Company].default([].freeze)
68
+ #
69
+ # Passing a named struct class AND a block will subclass the struct and extend it with new attributes:
70
+ #
71
+ # attribute :company, Company do
72
+ # attribute :address, String
73
+ # end
74
+ #
75
+ # The same works with arrays:
76
+ #
77
+ # attribute :companies, Types::Array[Company] do
78
+ # attribute :address, String
79
+ # end
80
+ #
81
+ # Note that this does NOT work with union'd or piped structs.
82
+ #
83
+ # attribute :company, Company | Person do
84
+ #
85
+ # ## Optional Attributes
86
+ # Using `attribute?` allows for optional attributes. If the attribute is not present, it will be set to `Undefined`.
87
+ #
88
+ # attribute? :company, Company
89
+ #
90
+ # ## Struct Inheritance
91
+ # Structs can inherit from other structs. This is useful for defining a base struct with common attributes.
92
+ #
93
+ # class BasePerson
94
+ # include Plumb::Attributes
95
+ #
96
+ # attribute :name, String
97
+ # end
98
+ #
99
+ # class Person < BasePerson
100
+ # attribute :age, Integer
101
+ # end
102
+ #
103
+ # ## [] Syntax
104
+ #
105
+ # The `[]` syntax can be used to define a struct in a single line.
106
+ # Like Plumb::Types::Hash, suffixing a key with `?` makes it optional.
107
+ #
108
+ # Person = Data[name: String, age?: Integer]
109
+ # person = Person.new(name: 'Jane')
110
+ #
111
+ def self.included(base)
112
+ base.send(:extend, ClassMethods)
113
+ end
114
+
115
+ attr_reader :errors, :attributes
116
+
117
+ def initialize(attrs = {})
118
+ assign_attributes(attrs)
119
+ end
120
+
121
+ def ==(other)
122
+ other.is_a?(self.class) && other.attributes == attributes
123
+ end
124
+
125
+ # @return [Boolean]
126
+ def valid? = !errors || errors.none?
127
+
128
+ # @param attrs [Hash]
129
+ # @return [Plumb::Attributes]
130
+ def with(attrs = BLANK_HASH)
131
+ self.class.new(attributes.merge(attrs))
132
+ end
133
+
134
+ def inspect
135
+ %(#<#{self.class}:#{object_id} [#{valid? ? 'valid' : 'invalid'}] #{attributes.map do |k, v|
136
+ [k, v.inspect].join(':')
137
+ end.join(' ')}>)
138
+ end
139
+
140
+ # @return [Hash]
141
+ def to_h
142
+ attributes.transform_values do |value|
143
+ case value
144
+ when ::Array
145
+ value.map { |v| v.respond_to?(:to_h) ? v.to_h : v }
146
+ else
147
+ value.respond_to?(:to_h) ? value.to_h : value
148
+ end
149
+ end
150
+ end
151
+
152
+ def deconstruct(...) = to_h.values.deconstruct(...)
153
+ def deconstruct_keys(...) = to_h.deconstruct_keys(...)
154
+
155
+ private
156
+
157
+ def assign_attributes(attrs = BLANK_HASH)
158
+ raise ArgumentError, 'Must be a Hash of attributes' unless attrs.is_a?(::Hash)
159
+
160
+ @errors = BLANK_HASH
161
+ result = self.class._schema.resolve(attrs)
162
+ @attributes = result.value
163
+ @errors = result.errors unless result.valid?
164
+ end
165
+
166
+ module ClassMethods
167
+ def _schema
168
+ @_schema ||= HashClass.new
169
+ end
170
+
171
+ def inherited(subclass)
172
+ _schema._schema.each do |key, type|
173
+ subclass.attribute(key, type)
174
+ end
175
+ super
176
+ end
177
+
178
+ # The Plumb::Step interface
179
+ # @param result [Plumb::Result::Valid]
180
+ # @return [Plumb::Result::Valid, Plumb::Result::Invalid]
181
+ def call(result)
182
+ return result if result.value.is_a?(self)
183
+ return result.invalid(errors: ['Must be a Hash of attributes']) unless result.value.is_a?(Hash)
184
+
185
+ instance = new(result.value)
186
+ instance.valid? ? result.valid(instance) : result.invalid(instance, errors: instance.errors)
187
+ end
188
+
189
+ # Person = Data[:name => String, :age => Integer, title?: String]
190
+ def [](type_specs)
191
+ klass = Class.new(self)
192
+ type_specs.each do |key, type|
193
+ klass.attribute(key, type)
194
+ end
195
+ klass
196
+ end
197
+
198
+ # node name for visitors
199
+ def node_name = :data
200
+
201
+ # attribute(:friend) { attribute(:name, String) }
202
+ # attribute(:friend, MyStruct) { attribute(:name, String) }
203
+ # attribute(:name, String)
204
+ # attribute(:friends, Types::Array) { attribute(:name, String) }
205
+ # attribute(:friends, Types::Array) # same as Types::Array[Types::Any]
206
+ # attribute(:friends, Types::Array[Person])
207
+ #
208
+ def attribute(name, type = Types::Any, &block)
209
+ key = Key.wrap(name)
210
+ name = key.to_sym
211
+ type = Composable.wrap(type)
212
+ if block_given? # :foo, Array[Data] or :foo, Struct
213
+ type = Types::Data if type == Types::Any
214
+ type = Plumb.decorate(type) do |node|
215
+ if node.is_a?(Plumb::ArrayClass)
216
+ child = node.children.first
217
+ child = Types::Data if child == Types::Any
218
+ Types::Array[build_nested(name, child, &block)]
219
+ elsif node.is_a?(Plumb::Step)
220
+ build_nested(name, node, &block)
221
+ elsif node.is_a?(Class) && node <= Plumb::Attributes
222
+ build_nested(name, node, &block)
223
+ else
224
+ node
225
+ end
226
+ end
227
+ end
228
+
229
+ @_schema = _schema + { key => type }
230
+ define_method(name) { @attributes[name] }
231
+ end
232
+
233
+ def attribute?(name, *args, &block)
234
+ attribute(Key.new(name, optional: true), *args, &block)
235
+ end
236
+
237
+ def build_nested(name, node, &block)
238
+ if node.is_a?(Class) && node <= Plumb::Attributes
239
+ sub = Class.new(node)
240
+ sub.instance_exec(&block)
241
+ __set_nested_class__(name, sub)
242
+ return Composable.wrap(sub)
243
+ end
244
+
245
+ return node unless node.is_a?(Plumb::Step)
246
+
247
+ child = node.children.first
248
+ return node unless child <= Plumb::Attributes
249
+
250
+ sub = Class.new(child)
251
+ sub.instance_exec(&block)
252
+ __set_nested_class__(name, sub)
253
+ Composable.wrap(sub)
254
+ end
255
+
256
+ def __set_nested_class__(name, klass)
257
+ name = name.to_s.split('_').map(&:capitalize).join.sub(/s$/, '')
258
+ const_set(name, klass) unless const_defined?(name)
259
+ end
260
+ end
261
+ end
262
+ end
data/lib/plumb/build.rb CHANGED
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'plumb/steppable'
3
+ require 'plumb/composable'
4
4
 
5
5
  module Plumb
6
6
  class Build
7
- include Steppable
7
+ include Composable
8
8
 
9
- attr_reader :type
9
+ attr_reader :children
10
10
 
11
11
  def initialize(type, factory_method: :new, &block)
12
12
  @type = type
13
13
  @block = block || ->(value) { type.send(factory_method, value) }
14
+ @children = [type].freeze
14
15
  freeze
15
16
  end
16
17
 
@@ -23,10 +23,6 @@ module Plumb
23
23
  NOOP = ->(result) { result }
24
24
 
25
25
  module Callable
26
- def metadata
27
- MetadataVisitor.call(self)
28
- end
29
-
30
26
  def resolve(value = Undefined)
31
27
  call(Result.wrap(value))
32
28
  end
@@ -43,9 +39,15 @@ module Plumb
43
39
  end
44
40
  end
45
41
 
46
- module Steppable
47
- include Callable
42
+ # This module gets included by Composable,
43
+ # but only when Composable is `included` in classes, not `extended`.
44
+ # The rule of this module is to assign a name to constants that point to Composable instances.
45
+ module Naming
46
+ attr_reader :name
48
47
 
48
+ # When including this module,
49
+ # define a #node_name method on the Composable instance
50
+ # #node_name is used by Visitors to determine the type of node.
49
51
  def self.included(base)
50
52
  nname = base.name.split('::').last
51
53
  nname.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
@@ -55,20 +57,6 @@ module Plumb
55
57
  base.define_method(:node_name) { nname }
56
58
  end
57
59
 
58
- def self.wrap(callable)
59
- if callable.is_a?(Steppable)
60
- callable
61
- elsif callable.is_a?(::Hash)
62
- HashClass.new(schema: callable)
63
- elsif callable.respond_to?(:call)
64
- Step.new(callable)
65
- else
66
- MatchClass.new(callable)
67
- end
68
- end
69
-
70
- attr_reader :name
71
-
72
60
  class Name
73
61
  def initialize(name)
74
62
  @name = name
@@ -94,17 +82,42 @@ module Plumb
94
82
  def inspect = name.to_s
95
83
 
96
84
  def node_name = self.class.name.split('::').last.to_sym
85
+ end
86
+
87
+ #  Composable mixes in composition methods to classes.
88
+ # such as #>>, #|, #not, and others.
89
+ # Any Composable class can participate in Plumb compositions.
90
+ module Composable
91
+ include Callable
92
+
93
+ # This only runs when including Composable,
94
+ # not extending classes with it.
95
+ def self.included(base)
96
+ base.send(:include, Naming)
97
+ end
98
+
99
+ def self.wrap(callable)
100
+ if callable.is_a?(Composable)
101
+ callable
102
+ elsif callable.is_a?(::Hash)
103
+ HashClass.new(schema: callable)
104
+ elsif callable.respond_to?(:call)
105
+ Step.new(callable)
106
+ else
107
+ MatchClass.new(callable)
108
+ end
109
+ end
97
110
 
98
111
  def defer(definition = nil, &block)
99
112
  Deferred.new(definition || block)
100
113
  end
101
114
 
102
115
  def >>(other)
103
- And.new(self, Steppable.wrap(other))
116
+ And.new(self, Composable.wrap(other))
104
117
  end
105
118
 
106
119
  def |(other)
107
- Or.new(self, Steppable.wrap(other))
120
+ Or.new(self, Composable.wrap(other))
108
121
  end
109
122
 
110
123
  def transform(target_type, callable = nil, &block)
@@ -115,8 +128,12 @@ module Plumb
115
128
  self >> MatchClass.new(block, error: errors, label: errors)
116
129
  end
117
130
 
118
- def meta(data = {})
119
- self >> Metadata.new(data)
131
+ def metadata(data = Undefined)
132
+ if data == Undefined
133
+ MetadataVisitor.call(self)
134
+ else
135
+ self >> Metadata.new(data)
136
+ end
120
137
  end
121
138
 
122
139
  def not(other = self)
@@ -138,7 +155,7 @@ module Plumb
138
155
  def [](val) = match(val)
139
156
 
140
157
  class Node
141
- include Steppable
158
+ include Composable
142
159
 
143
160
  attr_reader :node_name, :type, :attributes
144
161
 
@@ -168,11 +185,15 @@ module Plumb
168
185
  types = Array(metadata[:type]).uniq
169
186
 
170
187
  bargs = [self]
171
- bargs << rest.first if rest.any?
188
+ arg = Undefined
189
+ if rest.any?
190
+ bargs << rest.first
191
+ arg = rest.first
192
+ end
172
193
  block = Plumb.policies.get(types, name)
173
194
  pol = block.call(*bargs, &blk)
174
195
 
175
- Policy.new(name, rest.first, pol)
196
+ Policy.new(name, arg, pol)
176
197
  in [::Hash => opts] # #policy(p1: value, p2: value)
177
198
  opts.reduce(self) { |step, (name, value)| step.policy(name, value) }
178
199
  else
@@ -182,13 +203,19 @@ module Plumb
182
203
 
183
204
  def ===(other)
184
205
  case other
185
- when Steppable
206
+ when Composable
186
207
  other == self
187
208
  else
188
209
  resolve(other).valid?
189
210
  end
190
211
  end
191
212
 
213
+ def ==(other)
214
+ other.is_a?(self.class) && other.children == children
215
+ end
216
+
217
+ def children = BLANK_ARRAY
218
+
192
219
  def build(cns, factory_method = :new, &block)
193
220
  self >> Build.new(cns, factory_method:, &block)
194
221
  end
@@ -201,6 +228,12 @@ module Plumb
201
228
  inspect
202
229
  end
203
230
 
231
+ # @option root [Boolean] whether to include JSON Schema $schema property
232
+ # @return [Hash]
233
+ def to_json_schema(root: false)
234
+ JSONSchemaVisitor.call(self, root:)
235
+ end
236
+
204
237
  # Build a step that will invoke one or more methods on the value.
205
238
  # Ex 1: Types::String.invoke(:downcase)
206
239
  # Ex 2: Types::Array.invoke(:[], 1)