plumb 0.0.4 → 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 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