plumb 0.0.3 → 0.0.5

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +609 -57
  3. data/bench/compare_parametric_schema.rb +102 -0
  4. data/bench/compare_parametric_struct.rb +68 -0
  5. data/bench/parametric_schema.rb +229 -0
  6. data/bench/plumb_hash.rb +109 -0
  7. data/examples/concurrent_downloads.rb +3 -3
  8. data/examples/env_config.rb +2 -2
  9. data/examples/event_registry.rb +127 -0
  10. data/examples/weekdays.rb +1 -1
  11. data/lib/plumb/and.rb +4 -3
  12. data/lib/plumb/any_class.rb +4 -4
  13. data/lib/plumb/array_class.rb +8 -5
  14. data/lib/plumb/attributes.rb +268 -0
  15. data/lib/plumb/build.rb +4 -3
  16. data/lib/plumb/composable.rb +381 -0
  17. data/lib/plumb/decorator.rb +57 -0
  18. data/lib/plumb/deferred.rb +1 -1
  19. data/lib/plumb/hash_class.rb +19 -8
  20. data/lib/plumb/hash_map.rb +8 -6
  21. data/lib/plumb/interface_class.rb +6 -2
  22. data/lib/plumb/json_schema_visitor.rb +59 -32
  23. data/lib/plumb/match_class.rb +5 -4
  24. data/lib/plumb/metadata.rb +5 -1
  25. data/lib/plumb/metadata_visitor.rb +13 -42
  26. data/lib/plumb/not.rb +4 -3
  27. data/lib/plumb/or.rb +10 -4
  28. data/lib/plumb/pipeline.rb +27 -7
  29. data/lib/plumb/policy.rb +10 -3
  30. data/lib/plumb/schema.rb +11 -10
  31. data/lib/plumb/static_class.rb +4 -3
  32. data/lib/plumb/step.rb +4 -3
  33. data/lib/plumb/stream_class.rb +8 -7
  34. data/lib/plumb/tagged_hash.rb +11 -11
  35. data/lib/plumb/transform.rb +4 -3
  36. data/lib/plumb/tuple_class.rb +8 -8
  37. data/lib/plumb/type_registry.rb +5 -2
  38. data/lib/plumb/types.rb +30 -1
  39. data/lib/plumb/value_class.rb +4 -3
  40. data/lib/plumb/version.rb +1 -1
  41. data/lib/plumb/visitor_handlers.rb +6 -0
  42. data/lib/plumb.rb +11 -5
  43. metadata +10 -3
  44. data/lib/plumb/steppable.rb +0 -229
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+ Bundler.setup(:benchmark)
5
+
6
+ require 'benchmark/ips'
7
+ require 'money'
8
+ Money.rounding_mode = BigDecimal::ROUND_HALF_EVEN
9
+ Money.default_currency = 'GBP'
10
+ require_relative './parametric_schema'
11
+ require_relative './plumb_hash'
12
+
13
+ data = {
14
+ supplier_name: 'Vodafone',
15
+ start_date: '2020-01-01',
16
+ end_date: '2021-01-11',
17
+ countdown_date: '2021-01-11',
18
+ name: 'Vodafone TV',
19
+ upfront_cost_description: 'Upfront cost description',
20
+ tv_channels_count: 100,
21
+ terms: [
22
+ { name: 'Foo', url: 'http://foo.com', terms_text: 'Foo terms', start_date: '2020-01-01', end_date: '2021-01-01' },
23
+ { name: 'Foo2', url: 'http://foo2.com', terms_text: 'Foo terms', start_date: '2020-01-01', end_date: '2021-01-01' }
24
+ ],
25
+ tv_included: true,
26
+ additional_info: 'Additional info',
27
+ product_type: 'TV',
28
+ annual_price_increase_applies: true,
29
+ annual_price_increase_description: 'Annual price increase description',
30
+ broadband_components: [
31
+ {
32
+ name: 'Broadband 1',
33
+ technology: 'FTTP',
34
+ technology_tags: ['FTTP'],
35
+ is_mobile: false,
36
+ description: 'Broadband 1 description',
37
+ download_speed_measurement: 'Mbps',
38
+ download_speed: 100,
39
+ upload_speed_measurement: 'Mbps',
40
+ upload_speed: 100,
41
+ download_usage_limit: 1000,
42
+ discount_price: 100,
43
+ discount_period: 12,
44
+ speed_description: 'Speed description',
45
+ ongoing_price: 100,
46
+ contract_length: 12,
47
+ upfront_cost: 100,
48
+ commission: 100
49
+ }
50
+ ],
51
+ tv_components: [
52
+ {
53
+ slug: 'vodafone-tv',
54
+ name: 'Vodafone TV',
55
+ search_tags: %w[Vodafone TV],
56
+ description: 'Vodafone TV description',
57
+ channels: 100,
58
+ discount_price: 100
59
+ }
60
+ ],
61
+ call_package_types: ['Everything'],
62
+ phone_components: [
63
+ {
64
+ name: 'Phone 1',
65
+ description: 'Phone 1 description',
66
+ discount_price: 100,
67
+ disount_period: 12,
68
+ ongoing_price: 100,
69
+ contract_length: 12,
70
+ upfront_cost: 100,
71
+ commission: 100,
72
+ call_package_types: ['Everything']
73
+ }
74
+ ],
75
+ payment_methods: ['Credit Card', 'Paypal'],
76
+ discounts: [
77
+ { period: 12, price: 100 }
78
+ ],
79
+ ongoing_price: 100,
80
+ contract_length: 12,
81
+ upfront_cost: 100,
82
+ year_1_price: 100,
83
+ savings: 100,
84
+ commission: 100,
85
+ max_broadband_download_speed: 100
86
+ }
87
+
88
+ # p V1Schemas::RECORD.resolve(data).errors
89
+ # p V2Schemas::Record.resolve(data)
90
+ # result = Parametric::V2::Result.wrap(data)
91
+
92
+ # p result
93
+ # p V2Schema.call(result)
94
+ Benchmark.ips do |x|
95
+ x.report('Parametric::Schema') do
96
+ ParametricSchema::RECORD.resolve(data)
97
+ end
98
+ x.report('Plumb') do
99
+ PlumbHash::Record.resolve(data)
100
+ end
101
+ x.compare!
102
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+ Bundler.setup(:benchmark)
5
+
6
+ require 'benchmark/ips'
7
+ require 'parametric/struct'
8
+ require 'plumb'
9
+
10
+ module ParametricStruct
11
+ class User
12
+ include Parametric::Struct
13
+
14
+ schema do
15
+ field(:name).type(:string).present
16
+ field(:friends).type(:array).schema do
17
+ field(:name).type(:string).present
18
+ field(:age).type(:integer)
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ module PlumbStruct
25
+ include Plumb::Types
26
+
27
+ class User < Data
28
+ attribute :name, String.present
29
+ attribute :friends, Array do
30
+ attribute :name, String.present
31
+ attribute :age, Integer
32
+ end
33
+ end
34
+ end
35
+
36
+ module DataBaseline
37
+ Friend = Data.define(:name, :age)
38
+ User = Data.define(:name, :friends) do
39
+ def self.build(data)
40
+ data = data.merge(friends: data[:friends].map { |friend| Friend.new(**friend) })
41
+ new(**data)
42
+ end
43
+ end
44
+ end
45
+
46
+ data = {
47
+ name: 'John',
48
+ friends: [
49
+ { name: 'Jane', age: 30 },
50
+ { name: 'Joan', age: 38 }
51
+ ]
52
+ }
53
+
54
+ Benchmark.ips do |x|
55
+ # x.report('Ruby Data') do
56
+ # user = DataBaseline::User.build(data)
57
+ # user.name
58
+ # end
59
+ x.report('Parametric::Struct') do
60
+ user = ParametricStruct::User.new(data)
61
+ user.name
62
+ end
63
+ x.report('Plumb::Types::Data') do
64
+ user = PlumbStruct::User.new(data)
65
+ user.name
66
+ end
67
+ x.compare!
68
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parametric'
4
+ require 'money'
5
+ require 'monetize'
6
+
7
+ PARAMETRIC_MONEY_EXP = /(\W{1}|\w{3})?[\d+,.]/
8
+
9
+ Parametric.policy(:nullable_money) do
10
+ coerce do |value, _key, _context|
11
+ money = case value
12
+ when String
13
+ value = value.strip
14
+ if value.blank?
15
+ nil
16
+ elsif value =~ PARAMETRIC_MONEY_EXP
17
+ Monetize.parse!(value.gsub(',', ''))
18
+ else
19
+ raise ArgumentError, "#{value} is not a monetary amount"
20
+ end
21
+ when Numeric
22
+ Monetize.parse!(value.to_s)
23
+ when Money
24
+ value
25
+ when NilClass
26
+ nil
27
+ else
28
+ raise ArgumentError, "don't know how to coerce #{value.inspect} into Money"
29
+ end
30
+
31
+ if money && money.currency != Money.default_currency
32
+ raise ArgumentError,
33
+ "expected #{Money.default_currency.name} as currency, but got #{money.currency.name}"
34
+ end
35
+
36
+ money
37
+ end
38
+
39
+ meta_data do
40
+ { type: :number, config_type: :nullable_money }
41
+ end
42
+ end
43
+
44
+ Parametric.policy(:nullable_date) do
45
+ # rubocop:disable Style/RedundantBegin
46
+ coerce do |value, _key, _context|
47
+ begin
48
+ value = value.to_s.strip
49
+ value == '' ? nil : Date.parse(value)
50
+ rescue Date::Error
51
+ nil
52
+ end
53
+ end
54
+ # rubocop:enable Style/RedundantBegin
55
+
56
+ meta_data do
57
+ { type: :date, config_type: :nullable_date }
58
+ end
59
+ end
60
+
61
+ PARAMETRIC_DATE_EXP = /\A\d{4}-\d{2}-\d{2}\z/
62
+ PARAMETRIC_DATE_INFINITY = 'infinity'
63
+ PARAMETRIC_IS_DATE = ->(value) { value.is_a?(::Date) || (value.is_a?(::String) && value =~ PARAMETRIC_DATE_EXP) }
64
+ PARAMETRIC_PARSE_DATE = ->(value) { value.is_a?(::Date) ? value : ::Date.parse(value) }
65
+
66
+ Parametric.policy :nullable_date_range do
67
+ validate do |value, _key, _context|
68
+ if value.blank? || value.is_a?(Switcher::DateRange)
69
+ true
70
+ else
71
+ value.is_a?(Hash) && !!(PARAMETRIC_IS_DATE.call(value[:min]) && (value[:max].nil? || PARAMETRIC_IS_DATE.call(value[:max] || value[:max] == PARAMETRIC_DATE_INFINITY)))
72
+ end
73
+ end
74
+
75
+ coerce do |value, _key, _context|
76
+ if value.is_a?(Switcher::DateRange)
77
+ value
78
+ elsif value.blank? || (value[:min].blank? && value[:max].blank?)
79
+ nil
80
+ else
81
+ min = value[:min].present? ? PARAMETRIC_PARSE_DATE.call(value[:min]) : nil
82
+ max = value[:max].present? && value[:max] != PARAMETRIC_DATE_INFINITY ? PARAMETRIC_PARSE_DATE.call(value[:max]) : nil
83
+ Switcher::DateRange.new(min, max)
84
+ end
85
+ end
86
+
87
+ meta_data do
88
+ { type: :object, config_type: :nullable_date_range }
89
+ end
90
+ end
91
+
92
+ PARAMETRIC_INT_EXP = /^\d+$/
93
+
94
+ Parametric.policy(:nullable_integer) do
95
+ coerce do |value, _key, _context|
96
+ if value.to_s =~ PARAMETRIC_INT_EXP
97
+ value.to_i
98
+ else
99
+ nil
100
+ end
101
+ end
102
+
103
+ meta_data do
104
+ { type: :integer, config_type: :nullable_integer }
105
+ end
106
+ end
107
+
108
+ PARAMETRIC_FLOAT_EXP = /^\d+(\.\d+)?$/
109
+
110
+ Parametric.policy(:nullable_number) do
111
+ coerce do |value, _key, _context|
112
+ if value.to_s =~ PARAMETRIC_FLOAT_EXP
113
+ value.to_f
114
+ else
115
+ nil
116
+ end
117
+ end
118
+
119
+ meta_data do
120
+ { type: :number, config_type: :nullable_number }
121
+ end
122
+ end
123
+
124
+ Parametric.policy :size do
125
+ message do |opts, object|
126
+ "must have size of #{opts}, but got #{object.size}"
127
+ end
128
+
129
+ validate do |opts, object, _key, _payload|
130
+ size = object.size
131
+ (opts[:min].nil? || size >= opts[:min]) && (opts[:max].nil? || size <= opts[:max])
132
+ end
133
+ end
134
+
135
+ Parametric.policy(:nullable_string) do
136
+ coerce do |value, _key, _context|
137
+ if value.to_s.strip != ''
138
+ value.to_s
139
+ else
140
+ nil
141
+ end
142
+ end
143
+
144
+ meta_data do
145
+ { type: :string, config_type: :nullable_string }
146
+ end
147
+ end
148
+
149
+ module ParametricSchema
150
+ TERM = Parametric::Schema.new do
151
+ field(:name).type(:string).default('')
152
+ field(:url).type(:string).default('')
153
+ field(:terms_text).type(:string).default('')
154
+ field(:start_date).type(:nullable_date)
155
+ field(:end_date).type(:nullable_date)
156
+ end
157
+ TV_COMPONENT = Parametric::Schema.new do
158
+ field(:slug).type(:string) # .policy(:parameterize).present
159
+ field(:name).type(:string).present
160
+ field(:search_tags).type(:array).default([])
161
+ field(:description).type(:string)
162
+ field(:channels).type(:integer).default(0)
163
+ field(:discount_price).type(:nullable_money).default(Money.zero)
164
+ field(:discount_period).type(:nullable_integer)
165
+ field(:ongoing_price).type(:nullable_money)
166
+ field(:contract_length).type(:nullable_integer)
167
+ field(:upfront_cost).type(:nullable_money)
168
+ field(:commission).type(:nullable_money)
169
+ end
170
+ RECORD = Parametric::Schema.new do
171
+ field(:supplier_name).type(:string).present
172
+ field(:start_date).type(:nullable_date).meta(example: nil, admin_ui: true)
173
+ field(:end_date).type(:nullable_date).meta(example: nil, admin_ui: true)
174
+ field(:countdown_date).type(:nullable_date).meta(example: nil)
175
+ field(:name).type(:string).present.meta(example: 'Visa Platinum', admin_ui: true)
176
+ field(:upfront_cost_description).type(:string).default('')
177
+ field(:tv_channels_count).type(:integer).default(0)
178
+ field(:terms).type(:array).policy(:size, min: 1).default([]).schema TERM
179
+ field(:tv_included).type(:boolean)
180
+ field(:additional_info).type(:string)
181
+ field(:product_type).type(:nullable_string) # computed on ingestion
182
+ field(:annual_price_increase_applies).type(:boolean).default(false)
183
+ field(:annual_price_increase_description).type(:string).default('')
184
+ field(:broadband_components).type(:array).default([]).schema do
185
+ field(:name).type(:string)
186
+ field(:technology).type(:string)
187
+ field(:technology_tags).type(:array).default([])
188
+ field(:is_mobile).type(:boolean).default(false) # computed on ingestion based on technology
189
+ field(:description).type(:string)
190
+ field(:download_speed_measurement).type(:string).default('')
191
+ field(:download_speed).type(:nullable_number).default(0)
192
+ field(:upload_speed_measurement).type(:string)
193
+ field(:upload_speed).type(:nullable_number).default(0)
194
+ field(:download_usage_limit).type(:nullable_integer).default(nil)
195
+ field(:discount_price).type(:nullable_money)
196
+ field(:discount_period).type(:nullable_integer)
197
+ field(:speed_description).type(:string).default('')
198
+ field(:ongoing_price).type(:nullable_money)
199
+ field(:contract_length).type(:nullable_integer)
200
+ field(:upfront_cost).type(:nullable_money)
201
+ field(:commission).type(:nullable_money)
202
+ end
203
+ field(:tv_components).type(:array).default([]).schema TV_COMPONENT
204
+ field(:call_package_types).type(:array).default([]).meta(example: ['Everything']) # computed on ingestion
205
+ field(:phone_components).type(:array).default([]).schema do
206
+ field(:name).type(:string)
207
+ field(:description).type(:string)
208
+ field(:discount_price).type(:nullable_money)
209
+ field(:discount_period).type(:nullable_integer)
210
+ field(:ongoing_price).type(:nullable_money)
211
+ field(:contract_length).type(:nullable_integer)
212
+ field(:upfront_cost).type(:nullable_money)
213
+ field(:commission).type(:nullable_money)
214
+ field(:call_package_type).type(:array).default([])
215
+ end
216
+ field(:payment_methods).type(:array).default([])
217
+ field(:discounts).type(:array).default([]).schema do
218
+ field(:period).type(:integer)
219
+ field(:price).type(:nullable_money)
220
+ end
221
+ field(:ongoing_price).type(:nullable_money).meta(admin_ui: true)
222
+ field(:contract_length).type(:nullable_integer)
223
+ field(:upfront_cost).type(:nullable_money)
224
+ field(:year_1_price).type(:nullable_money).meta(admin_ui: true)
225
+ field(:savings).type(:nullable_money).meta(admin_ui: true)
226
+ field(:commission).type(:nullable_money)
227
+ field(:max_broadband_download_speed).type(:integer).default(0)
228
+ end
229
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'plumb'
4
+ require 'date'
5
+ require 'money'
6
+ require 'monetize'
7
+
8
+ module PlumbHash
9
+ include Plumb::Types
10
+
11
+ BLANK_ARRAY = [].freeze
12
+ BLANK_STRING = ''
13
+ MONEY_EXP = /(\W{1}|\w{3})?[\d+,.]/
14
+
15
+ PARSE_DATE = proc do |result|
16
+ date = ::Date.parse(result.value)
17
+ result.valid(date)
18
+ rescue ::Date::Error
19
+ result.invalid(errors: 'invalid date')
20
+ end
21
+
22
+ PARSE_MONEY = proc do |result|
23
+ value = Monetize.parse!(result.value.to_s.gsub(',', ''))
24
+ result.valid(value)
25
+ end
26
+
27
+ Date = Any[::Date] \
28
+ | (String[MONEY_EXP] >> PARSE_DATE)
29
+
30
+ BlankStringOrDate = Forms::Nil | Date
31
+
32
+ Money = Any[::Money] \
33
+ | (String.present >> PARSE_MONEY) \
34
+ | (Numeric >> PARSE_MONEY)
35
+
36
+ Term = Hash[
37
+ name: String.default(BLANK_STRING),
38
+ url: String.default(BLANK_STRING),
39
+ terms_text: String.default(BLANK_STRING),
40
+ start_date?: BlankStringOrDate.nullable,
41
+ end_date?: BlankStringOrDate.nullable
42
+ ]
43
+
44
+ TvComponent = Hash[
45
+ slug: String,
46
+ name: String.present,
47
+ search_tags: Array[String].default(BLANK_ARRAY),
48
+ description: String.default(BLANK_STRING),
49
+ channels: Integer.default(0),
50
+ discount_price: Money.default(::Money.zero.freeze)
51
+ ]
52
+
53
+ Record = Hash[
54
+ supplier_name: String.present,
55
+ start_date: BlankStringOrDate.nullable.metadata(admin_ui: true),
56
+ end_date: BlankStringOrDate.nullable.metadata(admin_ui: true),
57
+ countdown_date: BlankStringOrDate.nullable,
58
+ name: String.present,
59
+ upfront_cost_description: String.default(BLANK_STRING),
60
+ tv_channels_count: Integer.default(0),
61
+ terms: Array[Term].policy(size: 1..).default(BLANK_ARRAY),
62
+ tv_included: Boolean,
63
+ additional_info: String,
64
+ product_type: String.nullable,
65
+ annual_price_increase_applies: Boolean.default(false),
66
+ annual_price_increase_description: String.default(BLANK_STRING),
67
+ broadband_components: Array[
68
+ name: String,
69
+ technology: String,
70
+ is_mobile: Boolean.default(false),
71
+ desciption: String,
72
+ technology_tags: Array[String].default(BLANK_ARRAY),
73
+ download_speed_measurement: String.default(BLANK_STRING),
74
+ download_speed: Numeric.default(0),
75
+ upload_speed_measurement: String.default(BLANK_STRING),
76
+ upload_speed: Numeric.default(0),
77
+ download_usage_limit: Integer.nullable,
78
+ discount_price: Money.nullable,
79
+ discount_period: Integer.nullable,
80
+ speed_description: String.default(BLANK_STRING),
81
+ ongoing_price: Money.nullable,
82
+ contract_length: Integer.nullable,
83
+ upfront_cost: Money.nullable,
84
+ commission: Money.nullable
85
+ ],
86
+ tv_components: Array[TvComponent].default(BLANK_ARRAY),
87
+ call_package_types: Array[String].default(BLANK_ARRAY).metadata(example: ['Everything']),
88
+ phone_components: Array[
89
+ name: String,
90
+ description: String,
91
+ discount_price: Money.nullable,
92
+ discount_period: Integer.nullable,
93
+ ongoing_price: Money.nullable,
94
+ contract_length: Integer.nullable,
95
+ upfront_cost: Money.nullable,
96
+ commission: Money.nullable,
97
+ call_package_type: Array[String].default(BLANK_ARRAY)
98
+ ].default(BLANK_ARRAY),
99
+ payment_methods: Array[String].default(BLANK_ARRAY),
100
+ discounts: Array[period: Integer, price: Money.nullable],
101
+ ongoing_price: Money.nullable.metadata(admin_ui: true),
102
+ contract_length: Integer.nullable,
103
+ upfront_cost: Money.nullable,
104
+ year_1_price: Money.nullable.metadata(admin_ui: true),
105
+ savings: Money.nullable.metadata(admin_ui: true),
106
+ commission: Money.nullable,
107
+ max_broadband_download_speed: Integer.default(0)
108
+ ]
109
+ end
@@ -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,127 @@
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 string into a Time object
15
+ ISOTime = String.build(::Time, :parse).policy(:rescue, ArgumentError)
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, or generate a new one
21
+ AutoUUID = UUID::V4.default { SecureRandom.uuid }
22
+ end
23
+
24
+ # A superclass and registry to define event types
25
+ # for example for an event-driven or event-sourced system.
26
+ # All events have an "envelope" set of attributes,
27
+ # including unique ID, stream_id, type, timestamp, causation ID,
28
+ # event subclasses have a type string (ex. 'users.name.updated') and an optional payload
29
+ # This class provides a `.define` method to create new event types with a type and optional payload struct,
30
+ # a `.from` method to instantiate the correct subclass from a hash, ex. when deserializing from JSON or a web request.
31
+ # and a `#follow` method to produce new events based on a previous event's envelope, where the #causation_id and #correlation_id
32
+ # are set to the parent event
33
+ # @example
34
+ #
35
+ # # Define event struct with type and payload
36
+ # UserCreated = Event.define('users.created') do
37
+ # attribute :name, Types::String
38
+ # attribute :email, Types::Email
39
+ # end
40
+ #
41
+ # # Instantiate a full event with .new
42
+ # user_created = UserCreated.new(stream_id: 'user-1', payload: { name: 'Joe', email: '...' })
43
+ #
44
+ # # Use the `.from(Hash) => Event` factory to lookup event class by `type` and produce the right instance
45
+ # user_created = Event.from(type: 'users.created', stream_id: 'user-1', payload: { name: 'Joe', email: '...' })
46
+ #
47
+ # # Use #follow(payload Hash) => Event to produce events following a command or parent event
48
+ # create_user = CreateUser.new(...)
49
+ # user_created = create_user.follow(UserCreated, name: 'Joe', email: '...')
50
+ # user_created.causation_id == create_user.id
51
+ # user_created.correlation_id == create_user.correlation_id
52
+ # user_created.stream_id == create_user.stream_id
53
+ #
54
+ # ## JSON Schemas
55
+ # Plumb data structs support `.to_json_schema`, so you can document all events in the registry with something like
56
+ #
57
+ # Event.registry.values.map(&:to_json_schema)
58
+ #
59
+ class Event < Types::Data
60
+ attribute :id, Types::AutoUUID
61
+ attribute :stream_id, Types::String.present
62
+ attribute :type, Types::String
63
+ attribute(:created_at, Types::Time.default { ::Time.now })
64
+ attribute? :causation_id, Types::UUID::V4
65
+ attribute? :correlation_id, Types::UUID::V4
66
+ attribute :payload, Types::Static[nil]
67
+
68
+ def self.registry
69
+ @registry ||= {}
70
+ end
71
+
72
+ # Custom node_name to trigger specialiesed JSON Schema visitor handler.
73
+ def self.node_name = :event
74
+
75
+ def self.define(type_str, &payload_block)
76
+ type_str.freeze unless type_str.frozen?
77
+ registry[type_str] = Class.new(self) do
78
+ def self.node_name = :data
79
+
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
+
121
+ # Register a JSON Schema visitor handlers to render Event.registry as a "AnyOf" list of event types
122
+ Plumb::JSONSchemaVisitor.on(:event) do |node, props|
123
+ props.merge('type' => 'object', 'anyOf' => node.registry.values.map { |v| visit(v) })
124
+ end
125
+
126
+ p Plumb::JSONSchemaVisitor.call(Event)
127
+ # debugger
data/examples/weekdays.rb CHANGED
@@ -52,7 +52,7 @@ p Types::DayName.parse('TueSday') # => "tuesday
52
52
  p Types::Week.parse([3, 2, 1, 4, 5, 6, 7]) # => [1, 2, 3, 4, 5, 6, 7]
53
53
  p Types::Week.parse([1, 'Tuesday', 3, 4, 5, 'saturday', 7]) # => [1, 2, 3, 4, 5, 6, 7]
54
54
 
55
- # p Types::Week[[1, 1, 3, 4, 5, 6, 7]] # raises Plumb::TypeError: repeated days
55
+ # p Types::Week[[1, 1, 3, 4, 5, 6, 7]] # raises Plumb::ParseError: repeated days
56
56
  #
57
57
  # Or use these types as part of other composite types, ex.
58
58
  #
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