plumb 0.0.4 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6e1738eecc88a18de722a0411d2abb34fcad6bfe9dea7722d49b808b2d040d4
4
- data.tar.gz: 2038adbc2b2eacea52a6651d7e4f4332d6a280ce90f2725ea9fd91c9761f527c
3
+ metadata.gz: 896f2ec4a63cde86dd22aaec579696e19c980b09c09e4a4d9d4690f9505f742d
4
+ data.tar.gz: 3296480dd0026e8050b624e3e1d65a020fde376a87c4ba269c6481b57c40d19e
5
5
  SHA512:
6
- metadata.gz: ad40a1e6a2f1f1c4ae49f67448b210f87e0d23c3edf3fbd8b1ba5d408040dfc05801e15c4ef4126a373a94ea808cadad022528473cc3d5e5036e35b1b3c526ae
7
- data.tar.gz: 5a3aab711081e9a5b9531b7d792ebbe11b140bd39192fea1f64365c65aae5adbb24649bd56c395ac13accf50c377b7d1c6d94654077cef59e00e91af2d9a444f
6
+ metadata.gz: 76ecebbe1dc408630107170a213d0145af08bc8d9cec3ef28d66f8aa1eb3fee09352ae89c00aed1eb11b3a52709100f65e6f0a2a43b4a38ed777709f096f9198
7
+ data.tar.gz: 0cdbf9c24900fdbcd8393dc4f1d260063b303cc44cf84caa86bdb4d34a6edf77e1f75e431481121b9004c7fbac695f3021b4a7be965c34c1d4b96146f17c2c4c
data/README.md CHANGED
@@ -10,6 +10,8 @@ If you're after raw performance and versatility I strongly recommend you use the
10
10
 
11
11
  For a description of the core architecture you can read [this article](https://ismaelcelis.com/posts/composable-pipelines-in-ruby/).
12
12
 
13
+ Some use cases in the [examples directory](https://github.com/ismasan/plumb/tree/main/examples)
14
+
13
15
  ## Installation
14
16
 
15
17
  Install in your environment with `gem install plumb`, or in your `Gemfile` with
@@ -58,7 +60,7 @@ module Types
58
60
  end
59
61
 
60
62
  Types::String.parse("hello") # => "hello"
61
- Types::String.parse(10) # raises "Must be a String" (Plumb::TypeError)
63
+ Types::String.parse(10) # raises "Must be a String" (Plumb::ParseError)
62
64
  ```
63
65
 
64
66
  Plumb ships with basic types already defined, such as `Types::String` and `Types::Integer`. See the full list below.
@@ -75,7 +77,7 @@ Email.parse('hello@server.com') # 'hello@server.com'
75
77
  # Or a Range
76
78
  AdultAge = Types::Integer[18..]
77
79
  AdultAge.parse(20) # 20
78
- AdultAge.parse(17) # raises "Must be within 18.."" (Plumb::TypeError)
80
+ AdultAge.parse(17) # raises "Must be within 18.."" (Plumb::ParseError)
79
81
 
80
82
  # Or literal values
81
83
  Twenty = Types::Integer[20]
@@ -113,7 +115,7 @@ result.errors # 'must be an Integer'
113
115
 
114
116
  ```ruby
115
117
  Types::Integer.parse(10) # 10
116
- Types::Integer.parse('10') # raises Plumb::TypeError
118
+ Types::Integer.parse('10') # raises Plumb::ParseError
117
119
  ```
118
120
 
119
121
 
@@ -161,7 +163,7 @@ In other words, `A >> B` means "if A succeeds, pass its result to B. Otherwise r
161
163
  StringOrInt = Types::String | Types::Integer
162
164
  StringOrInt.parse('hello') # "hello"
163
165
  StringOrInt.parse(10) # 10
164
- StringOrInt.parse({}) # raises Plumb::TypeError
166
+ StringOrInt.parse({}) # raises Plumb::ParseError
165
167
  ```
166
168
 
167
169
  Custom default value logic for non-emails
@@ -230,6 +232,9 @@ You can see more use cases in [the examples directory](https://github.com/ismasa
230
232
  * `Types::Numeric`
231
233
  * `Types::String`
232
234
  * `Types::Hash`
235
+ * `Types::UUID::V4`
236
+ * `Types::Email`
237
+ * `Types::Date`
233
238
  * `Types::Lax::Integer`
234
239
  * `Types::Lax::String`
235
240
  * `Types::Lax::Symbol`
@@ -237,8 +242,9 @@ You can see more use cases in [the examples directory](https://github.com/ismasa
237
242
  * `Types::Forms::Nil`
238
243
  * `Types::Forms::True`
239
244
  * `Types::Forms::False`
245
+ * `Types::Forms::Date`
240
246
 
241
- TODO: date and datetime, UUIDs, Email, others.
247
+ TODO: datetime, others.
242
248
 
243
249
  ### Policies
244
250
 
@@ -261,7 +267,7 @@ Allow `nil` values.
261
267
  nullable_str = Types::String.nullable
262
268
  nullable_srt.parse(nil) # nil
263
269
  nullable_str.parse('hello') # 'hello'
264
- nullable_str.parse(10) # TypeError
270
+ nullable_str.parse(10) # ParseError
265
271
  ```
266
272
 
267
273
  Note that this just encapsulates the following composition:
@@ -522,7 +528,52 @@ CSVLine = Types::String.split(/\s*;\s*/)
522
528
  CSVLine.parse('a;b;c') # => ['a', 'b', 'c']
523
529
  ```
524
530
 
531
+ #### `:rescue`
532
+
533
+ Wraps a step's execution, rescues a specific exception and returns an invalid result.
534
+
535
+ Useful for turning a 3rd party library's exception into an invalid result that plays well with Plumb's type compositions.
536
+
537
+ Example: this is how `Types::Forms::Date` uses the `:rescue` policy to parse strings with `Date.parse` and turn `Date::Error` exceptions into Plumb errors.
538
+
539
+ ```ruby
540
+ # Accept a string that can be parsed into a Date
541
+ # via Date.parse
542
+ # If Date.parse raises a Date::Error, return a Result::Invalid with
543
+ # the exception's message as error message.
544
+ type = Types::String
545
+ .build(::Date, :parse)
546
+ .policy(:rescue, ::Date::Error)
547
+
548
+ type.resolve('2024-02-02') # => Result::Valid with Date object
549
+ type.resolve('2024-') # => Result::Invalid with error message
550
+ ```
551
+
552
+ ### `Types::Interface`
553
+
554
+ Use this for objects that must respond to one or more methods.
555
+
556
+ ```ruby
557
+ Iterable = Types::Interface[:each, :map]
558
+ Iterable.parse([1,2,3]) # => [1,2,3]
559
+ Iterable.parse(10) # => raises error
560
+ ```
561
+
562
+ This can be useful combined with `case` statements, too:
563
+
564
+ ```ruby
565
+ value = [1,2,3]
566
+ case value
567
+ when Iterable
568
+ # do something with array
569
+ when Stringable
570
+ # do something with string
571
+ when Readable
572
+ # do something with IO or similar
573
+ end
574
+ ```
525
575
 
576
+ TODO: make this a bit more advanced. Check for method arity.
526
577
 
527
578
  ### `Types::Hash`
528
579
 
@@ -904,6 +955,13 @@ person.valid? # false
904
955
  person.errors[:age] # 'must be an integer'
905
956
  ```
906
957
 
958
+ Data structs can also be defined from `Types::Hash` instances.
959
+
960
+ ```ruby
961
+ PersonHash = Types::Hash[name: String, age?: Integer]
962
+ PersonStruct = Types::Data[PersonHash]
963
+ ```
964
+
907
965
  #### `#with`
908
966
 
909
967
  Note that these instances cannot be mutated (there's no attribute setters), but they can be copied with partial attributes with `#with`
@@ -1062,13 +1120,168 @@ person.friend.name # 'joan'
1062
1120
  person.friend.friend # nil
1063
1121
  ```
1064
1122
 
1123
+ ### Plumb::Pipeline
1065
1124
 
1125
+ `Plumb::Pipeline` offers a sequential, step-by-step syntax for composing processing steps, as well as a simple middleware API to wrap steps for metrics, logging, debugging, caching and more. See the [command objects](https://github.com/ismasan/plumb/blob/main/examples/command_objects.rb) example for a worked use case.
1066
1126
 
1067
- ### Plumb::Schema
1127
+ #### `#pipeline` helper
1068
1128
 
1069
- TODO
1129
+ All plumb steps have a `#pipeline` helper.
1070
1130
 
1071
- ### Plumb::Pipeline
1131
+ ```ruby
1132
+ User = Types::Data[name: String, age: Integer]
1133
+
1134
+ CreateUser = User.pipeline do |pl|
1135
+ # Add steps as #call(Result) => Result interfaces
1136
+ pl.step ValidateUser.new
1137
+
1138
+ # Or as procs
1139
+ pl.step do |result|
1140
+ Logger.info "We have a valid user #{result.value}"
1141
+ result
1142
+ end
1143
+
1144
+ # Or as other Plumb steps
1145
+ pl.step User.transform(User) { |user| user.with(name: user.name.upcase) }
1146
+
1147
+ pl.step do |result|
1148
+ DB.create(result.value)
1149
+ end
1150
+ end
1151
+
1152
+ # User normally as any other Plumb step
1153
+ result = CreateUser.resolve(name: 'Joe', age: 40)
1154
+ # result.valid?
1155
+ # result.errors
1156
+ # result.value => User
1157
+ ```
1158
+
1159
+ Pipelines are Plumb steps, so they can be composed further.
1160
+
1161
+ ```ruby
1162
+ IsJoe = User.check('must be named joe') { |user|
1163
+ result.value.name == 'Joe'
1164
+ }
1165
+
1166
+ CreateIfJoe = IsJoe >> CreateUser
1167
+ ```
1168
+
1169
+ ##### `#around`
1170
+
1171
+ Use `#around` in a pipeline definition to add a middleware step that wraps all other steps registered.
1172
+
1173
+ ```ruby
1174
+ # The #around interface is #call(Step, Result::Valid) => Result::Valid | Result::Invalid
1175
+ StepLogger = proc do |step, result|
1176
+ Logger.info "Processing step #{step}"
1177
+ step.call(result)
1178
+ end
1179
+
1180
+ CreateUser = User.pipeline do |pl|
1181
+ # Around middleware will wrap all other steps registered below
1182
+ pl.around StepLogger
1183
+
1184
+ pl.step ValidateUser.new
1185
+ pl.step ...etc
1186
+ end
1187
+ ```
1188
+
1189
+ Note that order matters: an _around_ step will only wrap steps registered _after it_.
1190
+
1191
+ ```ruby
1192
+ # This step will not be wrapper by StepLogger
1193
+ pl.step Step1
1194
+
1195
+ pl.around StepLogger
1196
+ # This step WILL be wrapped
1197
+ pl.step Step2
1198
+ ```
1199
+
1200
+ Like regular steps, `around` middleware can be a class, an instance, a proc, or anything that implements the middleware interface.
1201
+
1202
+ ```ruby
1203
+ # As class instance
1204
+ # pl.around StepLogger.new(:warn)
1205
+ class StepLogger
1206
+ def initialize(level = :info)
1207
+ @level = level
1208
+ end
1209
+
1210
+ def call(step, result)
1211
+ Logger.send(@level) "Processing step #{step}"
1212
+ step.call(result)
1213
+ end
1214
+ end
1215
+
1216
+ # As proc
1217
+ pl.around do |step, result|
1218
+ Logger.info "Processing step #{step}"
1219
+ step.call(result)
1220
+ end
1221
+ ```
1222
+
1223
+ #### As stand-alone `Plumb::Pipeline` class
1224
+
1225
+ `Plumb::Pipeline` can also be used on its own, sub-classed, and it can take class-level `around` middleware.
1226
+
1227
+ ```ruby
1228
+ class LoggedPipeline < Plumb::Pipeline
1229
+ # class-level midleware will be inherited by sub-classes
1230
+ around StepLogged
1231
+ end
1232
+
1233
+ # Subclass inherits class-level middleware stack,
1234
+ # and it can also add its own class or instance-level middleware
1235
+ class ChildPipeline < LoggedPipeline
1236
+ # class-level middleware
1237
+ around Telemetry.new
1238
+ end
1239
+
1240
+ # Instantiate and add instance-level middleware
1241
+ pipe = ChildPipeline.new do |pl|
1242
+ pl.around NotifyErrors
1243
+ pl.step Step1
1244
+ pl.step Step2
1245
+ end
1246
+ ```
1247
+
1248
+ Sub-classing `Plumb::Pipeline` can be useful to add helpers or domain-specific functionality
1249
+
1250
+ ```ruby
1251
+ class DebuggablePipeline < LoggedPipeline
1252
+ # Use #debug! for inserting a debugger between steps
1253
+ def debug!
1254
+ step do |result|
1255
+ debugger
1256
+ result
1257
+ end
1258
+ end
1259
+ end
1260
+
1261
+ pipe = DebuggablePipeline.new do |pl|
1262
+ pl.step Step1
1263
+ pl.debug!
1264
+ pl.step Step2
1265
+ end
1266
+ ```
1267
+
1268
+ #### Pipelines all the way down :turtle:
1269
+
1270
+ Pipelines are full Plumb steps, so they can themselves be used as steps.
1271
+
1272
+ ```ruby
1273
+ Pipe1 = DebuggablePipeline.new do |pl|
1274
+ pl.step Step1
1275
+ pl.step Step2
1276
+ end
1277
+
1278
+ Pipe2 = DebuggablePipeline.new do |pl|
1279
+ pl.step Pipe1 # <= A pipeline instance as step
1280
+ pl.step Step3
1281
+ end
1282
+ ```
1283
+
1284
+ ### Plumb::Schema
1072
1285
 
1073
1286
  TODO
1074
1287
 
@@ -1388,7 +1601,7 @@ Types::DateTime.to_json_schema
1388
1601
  - [ ] benchmarks and performace. Compare with `Parametric`, `ActiveModel::Attributes`, `ActionController::StrongParameters`
1389
1602
  - [ ] flesh out `Plumb::Schema`
1390
1603
  - [x] `Plumb::Struct`
1391
- - [ ] flesh out and document `Plumb::Pipeline`
1604
+ - [x] flesh out and document `Plumb::Pipeline`
1392
1605
  - [ ] document custom visitors
1393
1606
  - [ ] Improve errors, support I18n ?
1394
1607
 
@@ -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