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.
- checksums.yaml +4 -4
- data/README.md +609 -57
- data/bench/compare_parametric_schema.rb +102 -0
- data/bench/compare_parametric_struct.rb +68 -0
- data/bench/parametric_schema.rb +229 -0
- data/bench/plumb_hash.rb +109 -0
- data/examples/concurrent_downloads.rb +3 -3
- data/examples/env_config.rb +2 -2
- data/examples/event_registry.rb +127 -0
- data/examples/weekdays.rb +1 -1
- data/lib/plumb/and.rb +4 -3
- data/lib/plumb/any_class.rb +4 -4
- data/lib/plumb/array_class.rb +8 -5
- data/lib/plumb/attributes.rb +268 -0
- data/lib/plumb/build.rb +4 -3
- data/lib/plumb/composable.rb +381 -0
- data/lib/plumb/decorator.rb +57 -0
- data/lib/plumb/deferred.rb +1 -1
- data/lib/plumb/hash_class.rb +19 -8
- data/lib/plumb/hash_map.rb +8 -6
- data/lib/plumb/interface_class.rb +6 -2
- data/lib/plumb/json_schema_visitor.rb +59 -32
- data/lib/plumb/match_class.rb +5 -4
- data/lib/plumb/metadata.rb +5 -1
- data/lib/plumb/metadata_visitor.rb +13 -42
- data/lib/plumb/not.rb +4 -3
- data/lib/plumb/or.rb +10 -4
- data/lib/plumb/pipeline.rb +27 -7
- data/lib/plumb/policy.rb +10 -3
- data/lib/plumb/schema.rb +11 -10
- data/lib/plumb/static_class.rb +4 -3
- data/lib/plumb/step.rb +4 -3
- data/lib/plumb/stream_class.rb +8 -7
- data/lib/plumb/tagged_hash.rb +11 -11
- data/lib/plumb/transform.rb +4 -3
- data/lib/plumb/tuple_class.rb +8 -8
- data/lib/plumb/type_registry.rb +5 -2
- data/lib/plumb/types.rb +30 -1
- data/lib/plumb/value_class.rb +4 -3
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +6 -0
- data/lib/plumb.rb +11 -5
- metadata +10 -3
- 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
|
data/bench/plumb_hash.rb
ADDED
@@ -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
|
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
|
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
|
|
data/examples/env_config.rb
CHANGED
@@ -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::
|
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/
|
3
|
+
require 'plumb/composable'
|
4
4
|
|
5
5
|
module Plumb
|
6
6
|
class And
|
7
|
-
include
|
7
|
+
include Composable
|
8
8
|
|
9
|
-
attr_reader :
|
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
|
|