plumb 0.0.4 → 0.0.6

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: 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