plumb 0.0.3 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -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)