plumb 0.0.4 → 0.0.6

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: 308e76909c6466b0a6c2cc9443498a267186344b9508b8f485975479e0ff165a
4
+ data.tar.gz: 8498e5a4619437b8f91b3baae4b2d208c27031a5617dba174d52893cd4e3a54a
5
5
  SHA512:
6
- metadata.gz: ad40a1e6a2f1f1c4ae49f67448b210f87e0d23c3edf3fbd8b1ba5d408040dfc05801e15c4ef4126a373a94ea808cadad022528473cc3d5e5036e35b1b3c526ae
7
- data.tar.gz: 5a3aab711081e9a5b9531b7d792ebbe11b140bd39192fea1f64365c65aae5adbb24649bd56c395ac13accf50c377b7d1c6d94654077cef59e00e91af2d9a444f
6
+ metadata.gz: d41ebdf232099770d04abc85f81ead1e8dc1d4f55eb1bc9265484401cfd0418e984d7cf97a67a6ef452d67f05c3f92e66e3e3fe64f11622acbb89e5c223c73b1
7
+ data.tar.gz: 5e2749e954fae81753d63d6d27b95a53f239b5ac6ad776755646d794fe7819b56087f48bd99394aeab2f40c64d45606cc413a1475512f6943279d51a7dd7d2b2
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
 
@@ -133,7 +135,7 @@ joe = User.parse({ name: 'Joe', email: 'joe@email.com', age: 20}) # returns vali
133
135
  Users.parse([joe]) # returns valid array of user hashes
134
136
  ```
135
137
 
136
- More about [Types::Array](#typeshash) and [Types::Array](#typesarray). There's also [tuples](#typestuple), [hash maps](#hash-maps) and [data structs](#typesdata), and it's possible to create your own composite types.
138
+ More about [Types::Hash](#typeshash) and [Types::Array](#typesarray). There's also [tuples](#typestuple), [hash maps](#hash-maps), [data structs](#typesdata) and [streams](#typesstream), and it's possible to create your own composite types.
137
139
 
138
140
  ### Type composition
139
141
 
@@ -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,13 @@ 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`
238
+ * `Types::Time`
239
+ * `Types::URI::Generic`
240
+ * `Types::URI::HTTP`
241
+ * `Types::URI::File`
233
242
  * `Types::Lax::Integer`
234
243
  * `Types::Lax::String`
235
244
  * `Types::Lax::Symbol`
@@ -237,8 +246,13 @@ You can see more use cases in [the examples directory](https://github.com/ismasa
237
246
  * `Types::Forms::Nil`
238
247
  * `Types::Forms::True`
239
248
  * `Types::Forms::False`
249
+ * `Types::Forms::Date`
250
+ * `Types::Forms::Time`
251
+ * `Types::Forms::URI::Generic`
252
+ * `Types::Forms::URI::HTTP`
253
+ * `Types::Forms::URI::File`
240
254
 
241
- TODO: date and datetime, UUIDs, Email, others.
255
+ TODO: datetime, others.
242
256
 
243
257
  ### Policies
244
258
 
@@ -261,7 +275,7 @@ Allow `nil` values.
261
275
  nullable_str = Types::String.nullable
262
276
  nullable_srt.parse(nil) # nil
263
277
  nullable_str.parse('hello') # 'hello'
264
- nullable_str.parse(10) # TypeError
278
+ nullable_str.parse(10) # ParseError
265
279
  ```
266
280
 
267
281
  Note that this just encapsulates the following composition:
@@ -522,7 +536,52 @@ CSVLine = Types::String.split(/\s*;\s*/)
522
536
  CSVLine.parse('a;b;c') # => ['a', 'b', 'c']
523
537
  ```
524
538
 
539
+ #### `:rescue`
540
+
541
+ Wraps a step's execution, rescues a specific exception and returns an invalid result.
542
+
543
+ Useful for turning a 3rd party library's exception into an invalid result that plays well with Plumb's type compositions.
544
+
545
+ 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.
546
+
547
+ ```ruby
548
+ # Accept a string that can be parsed into a Date
549
+ # via Date.parse
550
+ # If Date.parse raises a Date::Error, return a Result::Invalid with
551
+ # the exception's message as error message.
552
+ type = Types::String
553
+ .build(::Date, :parse)
554
+ .policy(:rescue, ::Date::Error)
555
+
556
+ type.resolve('2024-02-02') # => Result::Valid with Date object
557
+ type.resolve('2024-') # => Result::Invalid with error message
558
+ ```
559
+
560
+ ### `Types::Interface`
525
561
 
562
+ Use this for objects that must respond to one or more methods.
563
+
564
+ ```ruby
565
+ Iterable = Types::Interface[:each, :map]
566
+ Iterable.parse([1,2,3]) # => [1,2,3]
567
+ Iterable.parse(10) # => raises error
568
+ ```
569
+
570
+ This can be useful combined with `case` statements, too:
571
+
572
+ ```ruby
573
+ value = [1,2,3]
574
+ case value
575
+ when Iterable
576
+ # do something with array
577
+ when Stringable
578
+ # do something with string
579
+ when Readable
580
+ # do something with IO or similar
581
+ end
582
+ ```
583
+
584
+ TODO: make this a bit more advanced. Check for method arity.
526
585
 
527
586
  ### `Types::Hash`
528
587
 
@@ -773,13 +832,15 @@ Images = Types::Array[ImageDownload].concurrent
773
832
  Images.parse(['https://images.com/1.png', 'https://images.com/2.png'])
774
833
  ```
775
834
 
835
+ See the [concurrent downloads example](https://github.com/ismasan/plumb/blob/main/examples/concurrent_downloads.rb).
836
+
776
837
  TODO: pluggable concurrency engines (Async?)
777
838
 
778
839
  #### `#stream`
779
840
 
780
841
  Turn an Array definition into an enumerator that yields each element wrapped in `Result::Valid` or `Result::Invalid`.
781
842
 
782
- See `Types::Stream` below for more.
843
+ See [`Types::Stream`](#typesstream) below for more.
783
844
 
784
845
  #### `#filtered`
785
846
 
@@ -848,6 +909,8 @@ stream.each.with_index(1) do |result, line|
848
909
  end
849
910
  ```
850
911
 
912
+ See a more complete the [CSV Stream example](https://github.com/ismasan/plumb/blob/main/examples/csv_stream.rb)
913
+
851
914
  #### `Types::Stream#filtered`
852
915
 
853
916
  Use `#filtered` to turn a `Types::Stream` into a stream that only yields valid elements.
@@ -904,6 +967,13 @@ person.valid? # false
904
967
  person.errors[:age] # 'must be an integer'
905
968
  ```
906
969
 
970
+ Data structs can also be defined from `Types::Hash` instances.
971
+
972
+ ```ruby
973
+ PersonHash = Types::Hash[name: String, age?: Integer]
974
+ PersonStruct = Types::Data[PersonHash]
975
+ ```
976
+
907
977
  #### `#with`
908
978
 
909
979
  Note that these instances cannot be mutated (there's no attribute setters), but they can be copied with partial attributes with `#with`
@@ -995,7 +1065,25 @@ Note that this does NOT work with union'd or piped structs.
995
1065
  attribute :company, Company | Person do
996
1066
  ```
997
1067
 
1068
+ #### Shorthand array syntax
1069
+
1070
+ ```ruby
1071
+ attribute :things, [] # Same as attribute :things, Types::Array
1072
+ attribute :numbers, [Integer] # Same as attribute :numbers, Types::Array[Integer]
1073
+ attribute :people, [Person] # same as attribute :people, Types::Array[Person]
1074
+ attribute :friends, [Person] do # same as attribute :friends, Types::Array[Person] do...
1075
+ attribute :phone_number, Integer
1076
+ end
1077
+ ```
1078
+
1079
+ Note that, if you want to match an attribute value against a literal array, you need to use `#value`
1080
+
1081
+ ```ruby
1082
+ attribute :one_two_three, Types::Array.value[[1, 2, 3]])
1083
+ ```
1084
+
998
1085
  #### Optional Attributes
1086
+
999
1087
  Using `attribute?` allows for optional attributes. If the attribute is not present, these attribute values will be `nil`
1000
1088
 
1001
1089
  ```ruby
@@ -1062,13 +1150,168 @@ person.friend.name # 'joan'
1062
1150
  person.friend.friend # nil
1063
1151
  ```
1064
1152
 
1153
+ ### Plumb::Pipeline
1065
1154
 
1155
+ `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
1156
 
1067
- ### Plumb::Schema
1157
+ #### `#pipeline` helper
1068
1158
 
1069
- TODO
1159
+ All plumb steps have a `#pipeline` helper.
1070
1160
 
1071
- ### Plumb::Pipeline
1161
+ ```ruby
1162
+ User = Types::Data[name: String, age: Integer]
1163
+
1164
+ CreateUser = User.pipeline do |pl|
1165
+ # Add steps as #call(Result) => Result interfaces
1166
+ pl.step ValidateUser.new
1167
+
1168
+ # Or as procs
1169
+ pl.step do |result|
1170
+ Logger.info "We have a valid user #{result.value}"
1171
+ result
1172
+ end
1173
+
1174
+ # Or as other Plumb steps
1175
+ pl.step User.transform(User) { |user| user.with(name: user.name.upcase) }
1176
+
1177
+ pl.step do |result|
1178
+ DB.create(result.value)
1179
+ end
1180
+ end
1181
+
1182
+ # Use normally as any other Plumb step
1183
+ result = CreateUser.resolve(name: 'Joe', age: 40)
1184
+ # result.valid?
1185
+ # result.errors
1186
+ # result.value => User
1187
+ ```
1188
+
1189
+ Pipelines are Plumb steps, so they can be composed further.
1190
+
1191
+ ```ruby
1192
+ IsJoe = User.check('must be named joe') { |user|
1193
+ result.value.name == 'Joe'
1194
+ }
1195
+
1196
+ CreateIfJoe = IsJoe >> CreateUser
1197
+ ```
1198
+
1199
+ ##### `#around`
1200
+
1201
+ Use `#around` in a pipeline definition to add a middleware step that wraps all other steps registered.
1202
+
1203
+ ```ruby
1204
+ # The #around interface is #call(Step, Result::Valid) => Result::Valid | Result::Invalid
1205
+ StepLogger = proc do |step, result|
1206
+ Logger.info "Processing step #{step}"
1207
+ step.call(result)
1208
+ end
1209
+
1210
+ CreateUser = User.pipeline do |pl|
1211
+ # Around middleware will wrap all other steps registered below
1212
+ pl.around StepLogger
1213
+
1214
+ pl.step ValidateUser.new
1215
+ pl.step ...etc
1216
+ end
1217
+ ```
1218
+
1219
+ Note that order matters: an _around_ step will only wrap steps registered _after it_.
1220
+
1221
+ ```ruby
1222
+ # This step will not be wrapped by StepLogger
1223
+ pl.step Step1
1224
+
1225
+ pl.around StepLogger
1226
+ # This step WILL be wrapped
1227
+ pl.step Step2
1228
+ ```
1229
+
1230
+ Like regular steps, `around` middleware can be a class, an instance, a proc, or anything that implements the middleware interface.
1231
+
1232
+ ```ruby
1233
+ # As class instance
1234
+ # pl.around StepLogger.new(:warn)
1235
+ class StepLogger
1236
+ def initialize(level = :info)
1237
+ @level = level
1238
+ end
1239
+
1240
+ def call(step, result)
1241
+ Logger.send(@level) "Processing step #{step}"
1242
+ step.call(result)
1243
+ end
1244
+ end
1245
+
1246
+ # As proc
1247
+ pl.around do |step, result|
1248
+ Logger.info "Processing step #{step}"
1249
+ step.call(result)
1250
+ end
1251
+ ```
1252
+
1253
+ #### As stand-alone `Plumb::Pipeline` class
1254
+
1255
+ `Plumb::Pipeline` can also be used on its own, sub-classed, and it can take class-level `around` middleware.
1256
+
1257
+ ```ruby
1258
+ class LoggedPipeline < Plumb::Pipeline
1259
+ # class-level midleware will be inherited by sub-classes
1260
+ around StepLogger
1261
+ end
1262
+
1263
+ # Subclass inherits class-level middleware stack,
1264
+ # and it can also add its own class or instance-level middleware
1265
+ class ChildPipeline < LoggedPipeline
1266
+ # class-level middleware
1267
+ around Telemetry.new
1268
+ end
1269
+
1270
+ # Instantiate and add instance-level middleware
1271
+ pipe = ChildPipeline.new do |pl|
1272
+ pl.around NotifyErrors
1273
+ pl.step Step1
1274
+ pl.step Step2
1275
+ end
1276
+ ```
1277
+
1278
+ Sub-classing `Plumb::Pipeline` can be useful to add helpers or domain-specific functionality
1279
+
1280
+ ```ruby
1281
+ class DebuggablePipeline < LoggedPipeline
1282
+ # Use #debug! for inserting a debugger between steps
1283
+ def debug!
1284
+ step do |result|
1285
+ debugger
1286
+ result
1287
+ end
1288
+ end
1289
+ end
1290
+
1291
+ pipe = DebuggablePipeline.new do |pl|
1292
+ pl.step Step1
1293
+ pl.debug!
1294
+ pl.step Step2
1295
+ end
1296
+ ```
1297
+
1298
+ #### Pipelines all the way down :turtle:
1299
+
1300
+ Pipelines are full Plumb steps, so they can themselves be used as steps.
1301
+
1302
+ ```ruby
1303
+ Pipe1 = DebuggablePipeline.new do |pl|
1304
+ pl.step Step1
1305
+ pl.step Step2
1306
+ end
1307
+
1308
+ Pipe2 = DebuggablePipeline.new do |pl|
1309
+ pl.step Pipe1 # <= A pipeline instance as step
1310
+ pl.step Step3
1311
+ end
1312
+ ```
1313
+
1314
+ ### Plumb::Schema
1072
1315
 
1073
1316
  TODO
1074
1317
 
@@ -1388,7 +1631,7 @@ Types::DateTime.to_json_schema
1388
1631
  - [ ] benchmarks and performace. Compare with `Parametric`, `ActiveModel::Attributes`, `ActionController::StrongParameters`
1389
1632
  - [ ] flesh out `Plumb::Schema`
1390
1633
  - [x] `Plumb::Struct`
1391
- - [ ] flesh out and document `Plumb::Pipeline`
1634
+ - [x] flesh out and document `Plumb::Pipeline`
1392
1635
  - [ ] document custom visitors
1393
1636
  - [ ] Improve errors, support I18n ?
1394
1637
 
@@ -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