pipeable 0.0.1 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.adoc +134 -0
- data/README.adoc +574 -0
- data/lib/pipeable/composable.rb +12 -0
- data/lib/pipeable/pipe.rb +16 -0
- data/lib/pipeable/stepable.rb +51 -0
- data/lib/pipeable/steps/abstract.rb +25 -0
- data/lib/pipeable/steps/as.rb +12 -0
- data/lib/pipeable/steps/bind.rb +10 -0
- data/lib/pipeable/steps/check.rb +30 -0
- data/lib/pipeable/steps/container.rb +26 -0
- data/lib/pipeable/steps/fmap.rb +10 -0
- data/lib/pipeable/steps/insert.rb +27 -0
- data/lib/pipeable/steps/map.rb +10 -0
- data/lib/pipeable/steps/merge.rb +27 -0
- data/lib/pipeable/steps/or.rb +10 -0
- data/lib/pipeable/steps/tee.rb +22 -0
- data/lib/pipeable/steps/to.rb +27 -0
- data/lib/pipeable/steps/try.rb +23 -0
- data/lib/pipeable/steps/use.rb +19 -0
- data/lib/pipeable/steps/validate.rb +28 -0
- data/lib/pipeable.rb +16 -3
- data/pipeable.gemspec +30 -18
- data.tar.gz.sig +0 -0
- metadata +121 -49
- metadata.gz.sig +0 -0
- data/.gitignore +0 -17
- data/.travis.yml +0 -1
- data/Gemfile +0 -5
- data/LICENSE.txt +0 -22
- data/README.md +0 -46
- data/Rakefile +0 -1
- data/lib/pipeable/version.rb +0 -3
- data/spec/pipeable_spec.rb +0 -27
- data/spec/spec_helper.rb +0 -11
data/README.adoc
ADDED
@@ -0,0 +1,574 @@
|
|
1
|
+
:toc: macro
|
2
|
+
:toclevels: 5
|
3
|
+
:figure-caption!:
|
4
|
+
|
5
|
+
:command_pattern_link: link:https://alchemists.io/articles/command_pattern[Command Pattern]
|
6
|
+
:debug_link: link:https://github.com/ruby/debug[Debug]
|
7
|
+
:dry_container_link: link:https://dry-rb.org/gems/dry-container[Dry Container]
|
8
|
+
:dry_events_link: link:https://dry-rb.org/gems/dry-events[Dry Events]
|
9
|
+
:dry_monads_link: link:https://dry-rb.org/gems/dry-monads[Dry Monads]
|
10
|
+
:dry_schema_link: link:https://dry-rb.org/gems/dry-schema[Dry Schema]
|
11
|
+
:dry_validation_link: link:https://dry-rb.org/gems/dry-validation[Dry Validation]
|
12
|
+
:function_composition_link: link:https://alchemists.io/articles/ruby_function_composition[Function Composition]
|
13
|
+
:infusible_link: link:https://alchemists.io/projects/infusible[Infusible]
|
14
|
+
:railway_pattern_link: link:https://fsharpforfunandprofit.com/posts/recipe-part2[Railway Pattern]
|
15
|
+
|
16
|
+
= Pipeable
|
17
|
+
|
18
|
+
A DSL for workflows built atop native {function_composition_link} which leverages the {railway_pattern_link}. This allows you to write a sequence of _steps_ that cleanly read from left-to-right or top-to-bottom which results in a success or a failure without having to rely on exceptions which are expensive and should not be used for control flow.
|
19
|
+
|
20
|
+
toc::[]
|
21
|
+
|
22
|
+
== Features
|
23
|
+
|
24
|
+
* Built atop of native {function_composition_link}.
|
25
|
+
* Adheres to the {railway_pattern_link}.
|
26
|
+
* Provides built-in and customizable domain-specific steps.
|
27
|
+
* Provides chainable _pipes_ which can be used to build more complex workflows.
|
28
|
+
* Compatible with {dry_monads_link}.
|
29
|
+
* Compatible with {infusible_link}.
|
30
|
+
|
31
|
+
== Requirements
|
32
|
+
|
33
|
+
. link:https://www.ruby-lang.org[Ruby].
|
34
|
+
. A strong understanding of {function_composition_link}.
|
35
|
+
|
36
|
+
== Setup
|
37
|
+
|
38
|
+
To install _with_ security, run:
|
39
|
+
|
40
|
+
[source,bash]
|
41
|
+
----
|
42
|
+
# 💡 Skip this line if you already have the public certificate installed.
|
43
|
+
gem cert --add <(curl --compressed --location https://alchemists.io/gems.pem)
|
44
|
+
gem install pipeable --trust-policy HighSecurity
|
45
|
+
----
|
46
|
+
|
47
|
+
To install _without_ security, run:
|
48
|
+
|
49
|
+
[source,bash]
|
50
|
+
----
|
51
|
+
gem install pipeable
|
52
|
+
----
|
53
|
+
|
54
|
+
You can also add the gem directly to your project:
|
55
|
+
|
56
|
+
[source,bash]
|
57
|
+
----
|
58
|
+
bundle add pipeable
|
59
|
+
----
|
60
|
+
|
61
|
+
Once the gem is installed, you only need to require it:
|
62
|
+
|
63
|
+
[source,ruby]
|
64
|
+
----
|
65
|
+
require "pipeable"
|
66
|
+
----
|
67
|
+
|
68
|
+
== Usage
|
69
|
+
|
70
|
+
You can turn any object into a _transaction_ by requiring and including this gem as follows:
|
71
|
+
|
72
|
+
[source,ruby]
|
73
|
+
----
|
74
|
+
require "csv"
|
75
|
+
require "pipeable"
|
76
|
+
|
77
|
+
class Demo
|
78
|
+
include Pipeable
|
79
|
+
|
80
|
+
def initialize client: CSV
|
81
|
+
@client = client
|
82
|
+
end
|
83
|
+
|
84
|
+
def call data
|
85
|
+
pipe data,
|
86
|
+
check(/Book.+Price/, :match?),
|
87
|
+
:parse,
|
88
|
+
map { |item| "#{item[:book]}: #{item[:price]}" }
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
attr_reader :client
|
94
|
+
|
95
|
+
def parse result
|
96
|
+
result.fmap do |data|
|
97
|
+
client.instance(data, headers: true, header_converters: proc { |key| key.downcase.to_sym })
|
98
|
+
.to_a
|
99
|
+
.map(&:to_h)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
----
|
104
|
+
|
105
|
+
The above allows `Demo#call` to be a sequence steps which may pass or fail due to all step being {dry_monads_link}. This is the essence of the {railway_pattern_link}.
|
106
|
+
|
107
|
+
To execute the above example, you'd only need to pass CSV content to it:
|
108
|
+
|
109
|
+
[source,ruby]
|
110
|
+
----
|
111
|
+
Demo.new.call <<~CSV
|
112
|
+
Book,Author,Price,At
|
113
|
+
Mystics,urGoh,10.50,2022-01-01
|
114
|
+
Skeksis,skekSil,20.75,2022-02-13
|
115
|
+
CSV
|
116
|
+
----
|
117
|
+
|
118
|
+
The computed result is a success with each book listing a price:
|
119
|
+
|
120
|
+
....
|
121
|
+
Success ["Mystics: 10.50", "Skeksis: 20.75"]
|
122
|
+
....
|
123
|
+
|
124
|
+
=== Pipe
|
125
|
+
|
126
|
+
Once you've included the `Pipeable` module within your class, the `#pipe` method is available to you and is how you build a sequence of steps for processing. The method signature is:
|
127
|
+
|
128
|
+
[source,ruby]
|
129
|
+
----
|
130
|
+
pipe(input, *steps)
|
131
|
+
----
|
132
|
+
|
133
|
+
The first argument is your input which can be a Ruby primitive or a monad. Regardless, the input will be automatically wrapped as a `Success` -- but only if not a `Result` to begin with -- before passing to the first step. From there, all steps are _required_ to answer a monad in order to adhere to the {railway_pattern_link}.
|
134
|
+
|
135
|
+
Behind the scenes, the `#pipe` method is syntactic sugar on top of {function_composition_link} which means if this code were to be rewritten:
|
136
|
+
|
137
|
+
[source,ruby]
|
138
|
+
----
|
139
|
+
pipe csv,
|
140
|
+
check(/Book.+Price/, :match?),
|
141
|
+
:parse,
|
142
|
+
map { |item| "#{item[:book]}: #{item[:price]}" }
|
143
|
+
----
|
144
|
+
|
145
|
+
Then the above would look like this using native Ruby:
|
146
|
+
|
147
|
+
[source,ruby]
|
148
|
+
----
|
149
|
+
(
|
150
|
+
check(/Book.+Price/, :match?) >>
|
151
|
+
method(:parse) >>
|
152
|
+
map { |item| "#{item[:book]}: #{item[:price]}" }
|
153
|
+
).call Success(csv)
|
154
|
+
----
|
155
|
+
|
156
|
+
The problem with native function composition is that it reads backwards by passing your input at the end of all sequential steps. With the `#pipe` method, you have the benefit of allowing your eye to read the code from top to bottom in addition to not having to type multiple _forward composition_ operators.
|
157
|
+
|
158
|
+
=== Steps
|
159
|
+
|
160
|
+
There are several ways to compose steps for your pipe. As long as all steps succeed, you'll get a successful response. Otherwise, the first step to fail will pass the failure down by skipping all subsequent steps (unless you dynamically attempt to turn the failure into a success). The following sections detail how to mix and match steps for building a robust implementation.
|
161
|
+
|
162
|
+
==== Basic
|
163
|
+
|
164
|
+
The following are the basic (default) steps for building for more advanced functionality.
|
165
|
+
|
166
|
+
===== As
|
167
|
+
|
168
|
+
Allows you to message the input as different output. Example:
|
169
|
+
|
170
|
+
[source,ruby]
|
171
|
+
----
|
172
|
+
pipe :a, as(:inspect) # Success ":a"
|
173
|
+
pipe %i[a b c], as(:dig, 1) # Success :b
|
174
|
+
pipe Failure("Danger!"), as(:inspect) # Failure "Danger!"
|
175
|
+
----
|
176
|
+
|
177
|
+
===== Bind
|
178
|
+
|
179
|
+
Allows you to perform operations on a successful result only. You are then responsible for answering a success or failure accordingly. This is a convenience wrapper to native {dry_monads_link} `#bind` functionality. Example:
|
180
|
+
|
181
|
+
[source,ruby]
|
182
|
+
----
|
183
|
+
pipe %i[a b c], bind { |input| Success input.join("-") } # Success "a-b-c"
|
184
|
+
pipe %i[a b c], bind { |input| Failure input } # Failure [:a, :b, :c]
|
185
|
+
pipe Failure("Danger!"), bind { |input| Success input.join("-") } # Failure "Danger!"
|
186
|
+
----
|
187
|
+
|
188
|
+
===== Check
|
189
|
+
|
190
|
+
Allows you to check if the input and messaged object evaluate to `true` or `Success`. When successful, input is passed through as a `Success`. When false, input is passed through as a `Failure`. Example:
|
191
|
+
|
192
|
+
[source,ruby]
|
193
|
+
----
|
194
|
+
pipe :a, check(%i[a b], :include?) # Success :a
|
195
|
+
pipe :a, check(%i[b c], :include?) # Failure :a
|
196
|
+
pipe Failure("Danger!"), check(%i[a b], :include?) # Failure "Danger!"
|
197
|
+
----
|
198
|
+
|
199
|
+
===== Fmap
|
200
|
+
|
201
|
+
Allows you to unwrap a successful operation, make a modification, and rewrap the modification as a new success. This is a convenience wrapper to native {dry_monads_link} `#fmap` functionality. Example:
|
202
|
+
|
203
|
+
[source,ruby]
|
204
|
+
----
|
205
|
+
pipe %i[a b c], fmap { |input| input.join "-" } # Success "a-b-c"
|
206
|
+
pipe Failure("Danger!"), fmap { |input| input.join "-" } # Failure "Danger!"
|
207
|
+
----
|
208
|
+
|
209
|
+
===== Insert
|
210
|
+
|
211
|
+
Allows you to insert an element after the input (default behavior) and wraps native link:https://rubyapi.org/o/array#method-i-insert[Array#insert] functionality. If the input is not an array, it will be cast as one. You can use the `:at` key to specify where you want insertion to happen. This step is most useful when needing to assemble arguments for passing to a subsequent step. Example:
|
212
|
+
|
213
|
+
[source,ruby]
|
214
|
+
----
|
215
|
+
pipe :a, insert(:b) # Success [:a, :b]
|
216
|
+
pipe :a, insert(:b, at: 0) # Success [:b, :a]
|
217
|
+
pipe %i[a c], insert(:b, at: 1) # Success [:a, :b, :c]
|
218
|
+
pipe Failure("Danger!"), insert(:b) # Failure "Danger!"
|
219
|
+
----
|
220
|
+
|
221
|
+
===== Map
|
222
|
+
|
223
|
+
Allows you to map over an enumerable and wraps native link:https://rubyapi.org/o/enumerable#method-i-map[Enumerable#map] functionality.
|
224
|
+
|
225
|
+
[source,ruby]
|
226
|
+
----
|
227
|
+
pipe %i[a b c], map(&:inspect) # Success [":a", ":b", ":c"]
|
228
|
+
pipe Failure("Danger!"), map(&:inspect) # Failure "Danger!"
|
229
|
+
----
|
230
|
+
|
231
|
+
===== Merge
|
232
|
+
|
233
|
+
Allows you to merge the input with additional attributes as a single hash. If the input is not a hash, then the input will be merged with the attributes using `step` as the key. The default `step` key can be renamed to a different key by using the `:as` key. Like the _Insert_ step, this is most useful when needing to assemble arguments and/or data for consumption by subsequent steps. Example:
|
234
|
+
|
235
|
+
[source,ruby]
|
236
|
+
----
|
237
|
+
pipe({a: 1}, merge(b: 2)) # Success {a: 1, b: 2}
|
238
|
+
pipe "test", merge(b: 2) # Success {step: "test", b: 2}
|
239
|
+
pipe "test", merge(as: :a, b: 2) # Success {a: "test", b: 2}
|
240
|
+
pipe Failure("Danger!"), merge(b: 2) # Failure "Danger!"
|
241
|
+
----
|
242
|
+
|
243
|
+
===== Orr
|
244
|
+
|
245
|
+
Allows you to operate on a failure and produce either a success or another failure. This is a convenience wrapper to native {dry_monads_link} `#or` functionality.
|
246
|
+
|
247
|
+
ℹ️ Syntactically, `or` can't be used for this step since `or` is a native Ruby keyword so `orr` is used instead.
|
248
|
+
|
249
|
+
Example:
|
250
|
+
|
251
|
+
[source,ruby]
|
252
|
+
----
|
253
|
+
pipe %i[a b c], orr { |input| Success input.join("-") } # Success [:a, :b, :c]
|
254
|
+
pipe Failure("Danger!"), orr { Success "Resolved" } # Success "Resolved"
|
255
|
+
pipe Failure("Danger!"), orr { |input| Failure "Big #{input}" } # Failure "Big Danger!"
|
256
|
+
----
|
257
|
+
|
258
|
+
===== Tee
|
259
|
+
|
260
|
+
Allows you to run an operation and ignore the response while input is passed through as output. This behavior is similar in nature to the link:https://www.gnu.org/savannah-checkouts/gnu/gawk/manual/html_node/Tee-Program.html[tee] program in Bash. Example:
|
261
|
+
|
262
|
+
[source,ruby]
|
263
|
+
----
|
264
|
+
pipe "test", tee(Kernel, :puts, "Example.")
|
265
|
+
|
266
|
+
# Example.
|
267
|
+
# Success "test"
|
268
|
+
|
269
|
+
pipe Failure("Danger!"), tee(Kernel, :puts, "Example.")
|
270
|
+
|
271
|
+
# Example.
|
272
|
+
# Failure "Danger!"
|
273
|
+
----
|
274
|
+
|
275
|
+
===== To
|
276
|
+
|
277
|
+
Allows you to delegate to an object -- which doesn't have a callable interface and may or may not answer a result -- for processing of input. If the response is not a monad, it'll be automatically wrapped as a `Success`. Example:
|
278
|
+
|
279
|
+
[source,ruby]
|
280
|
+
----
|
281
|
+
Model = Struct.new :label, keyword_init: true do
|
282
|
+
include Dry::Monads[:result]
|
283
|
+
|
284
|
+
def self.for(...) = Success new(...)
|
285
|
+
end
|
286
|
+
|
287
|
+
pipe({label: "Test"}, to(Model, :for)) # Success #<struct Model label="Test">
|
288
|
+
pipe Failure("Danger!"), to(Model, :for) # Failure "Danger!"
|
289
|
+
----
|
290
|
+
|
291
|
+
===== Try
|
292
|
+
|
293
|
+
Allows you to try an operation which may fail while catching the exception as a failure for further processing. Example:
|
294
|
+
|
295
|
+
[source,ruby]
|
296
|
+
----
|
297
|
+
pipe "test", try(:to_json, catch: JSON::ParserError) # Success "\"test\""
|
298
|
+
pipe "test", try(:invalid, catch: NoMethodError) # Failure "undefined method..."
|
299
|
+
pipe Failure("Danger!"), try(:to_json, catch: JSON::ParserError) # Failure "Danger!"
|
300
|
+
----
|
301
|
+
|
302
|
+
===== Use
|
303
|
+
|
304
|
+
Allows you to use another transaction which might have multiple steps of it's own, use an object that adheres to the {command_pattern_link}, or any function which answers a {dry_monads_link} `Result` object. In other words, you can use _use_ any object which responds to `#call` and answers a {dry_monads_link} `Result` object. This is great for chaining multiple transactions together.
|
305
|
+
|
306
|
+
[source,ruby]
|
307
|
+
----
|
308
|
+
function = -> input { Success input * 3 }
|
309
|
+
|
310
|
+
pipe 3, use(function) # Success 9
|
311
|
+
pipe Failure("Danger!"), use(function) # Failure "Danger!"
|
312
|
+
----
|
313
|
+
|
314
|
+
===== Validate
|
315
|
+
|
316
|
+
Allows you to use an operation that will validate the input. This is especially useful when using {dry_schema_link}, {dry_validation_link}, or any operation that can respond to `#call` while answering a result that can be converted into a hash.
|
317
|
+
|
318
|
+
By default, the `:as` key uses `:to_h` as it's value so you get automatic casting to a `Hash`. Use `nil`, as the value, to disable this behavior. You can also pass in any value to the `:as` key which is a valid method that the result will respond to.
|
319
|
+
|
320
|
+
[source,ruby]
|
321
|
+
----
|
322
|
+
schema = Dry::Schema.Params { required(:label).filled :string }
|
323
|
+
|
324
|
+
pipe({label: "Test"}, validate(schema)) # Success label: "Test"
|
325
|
+
pipe({label: "Test"}, validate(schema, as: nil)) # Success #<Dry::Schema::Result{:label=>"Test"} errors={} path=[]>
|
326
|
+
pipe Failure("Danger!"), validate(schema) # Failure "Danger!"
|
327
|
+
----
|
328
|
+
|
329
|
+
==== Advanced
|
330
|
+
|
331
|
+
Several options are available should you need to advance beyond the basic steps. Each is described in detail below.
|
332
|
+
|
333
|
+
===== Procs
|
334
|
+
|
335
|
+
You can always use a `Proc` as a custom step. Example:
|
336
|
+
|
337
|
+
[source,ruby]
|
338
|
+
----
|
339
|
+
include Pipeable
|
340
|
+
include Dry::Monads[:result]
|
341
|
+
|
342
|
+
pipe :a,
|
343
|
+
insert(:b),
|
344
|
+
proc { Success "input_ignored" },
|
345
|
+
as(:to_sym)
|
346
|
+
|
347
|
+
# Yields: Success :input_ignored
|
348
|
+
----
|
349
|
+
|
350
|
+
===== Lambdas
|
351
|
+
|
352
|
+
In addition to procs, lambdas can be used too. Example:
|
353
|
+
|
354
|
+
[source,ruby]
|
355
|
+
----
|
356
|
+
include Pipeable
|
357
|
+
|
358
|
+
pipe :a,
|
359
|
+
insert(:b),
|
360
|
+
-> result { result.fmap { |input| input.join "_" } },
|
361
|
+
as(:to_sym)
|
362
|
+
|
363
|
+
# Yields: Success :a_b
|
364
|
+
----
|
365
|
+
|
366
|
+
===== Methods
|
367
|
+
|
368
|
+
Methods -- in addition to procs and lambdas -- are the _preferred_ way to add custom steps due to the concise syntax. Example:
|
369
|
+
|
370
|
+
[source,ruby]
|
371
|
+
----
|
372
|
+
class Demo
|
373
|
+
include Pipeable
|
374
|
+
|
375
|
+
def call input
|
376
|
+
pipe :a,
|
377
|
+
insert(:b),
|
378
|
+
:join,
|
379
|
+
as(:to_sym)
|
380
|
+
end
|
381
|
+
|
382
|
+
private
|
383
|
+
|
384
|
+
def join(result) = result.fmap { |input| input.join "_" }
|
385
|
+
end
|
386
|
+
|
387
|
+
Demo.new.call :a # Yields: Success :a_b
|
388
|
+
----
|
389
|
+
|
390
|
+
All methods can be referenced by symbol as shown via `:join` above. Using a symbol is syntactic sugar for link:https://rubyapi.org/o/object#method-i-method[Object#method] so the use of the `:join` symbol is the same as using `method(:join)`. Both work but the former requires less typing than the latter.
|
391
|
+
|
392
|
+
===== Custom
|
393
|
+
|
394
|
+
If you'd like to define permanent and reusable steps, you can register a custom step which requires you to:
|
395
|
+
|
396
|
+
. Define a custom step as a new class.
|
397
|
+
. Register your custom step along side the existing default steps.
|
398
|
+
|
399
|
+
Here's what this would look like:
|
400
|
+
|
401
|
+
[source,ruby]
|
402
|
+
----
|
403
|
+
module CustomSteps
|
404
|
+
class Join < Pipeable::Steps::Abstract
|
405
|
+
def initialize(delimiter = "_", **)
|
406
|
+
super(**)
|
407
|
+
@delimiter = delimiter
|
408
|
+
end
|
409
|
+
|
410
|
+
def call(result) = result.fmap { |input| input.join delimiter }
|
411
|
+
|
412
|
+
private
|
413
|
+
|
414
|
+
attr_reader :delimiter
|
415
|
+
end
|
416
|
+
end
|
417
|
+
|
418
|
+
Pipeable::Steps::Container.register(:join) { CustomSteps::Join }
|
419
|
+
|
420
|
+
include Pipeable
|
421
|
+
|
422
|
+
pipe :a, insert(:b), join, as(:to_sym)
|
423
|
+
# Yields: Success :a_b
|
424
|
+
|
425
|
+
pipe :a, insert(:b), join(""), as(:to_sym)
|
426
|
+
# Yields: Success :ab
|
427
|
+
----
|
428
|
+
|
429
|
+
=== Containers
|
430
|
+
|
431
|
+
Should you not want the basic steps, need custom steps, or a hybrid of default and custom steps, you can define your own container and provide it as an argument to `.with` when including pipeable behavior. Example:
|
432
|
+
|
433
|
+
[source,ruby]
|
434
|
+
----
|
435
|
+
require "dry/container"
|
436
|
+
|
437
|
+
module CustomContainer
|
438
|
+
extend Dry::Container::Mixin
|
439
|
+
|
440
|
+
register :echo, -> result { result }
|
441
|
+
register(:insert) { Pipeable::Steps::Insert }
|
442
|
+
end
|
443
|
+
|
444
|
+
include Pipeable.with(CustomContainer)
|
445
|
+
|
446
|
+
pipe :a, echo, insert(:b)
|
447
|
+
|
448
|
+
# Yields: Success [:a, :b]
|
449
|
+
----
|
450
|
+
|
451
|
+
The above is a hybrid example where the `CustomContainer` registers a custom `echo` step along with the default `insert` step to make a new container. This is included when passed in as an argument via `.with` (i.e. `include Pipeable.with(CustomContainer)`).
|
452
|
+
|
453
|
+
Whether you use default, custom, or hybrid steps, you have maximum flexibility using this approach.
|
454
|
+
|
455
|
+
=== Composition
|
456
|
+
|
457
|
+
Should you ever need to make a plain old Ruby object functionally composable, then you can _include_ the `Pipeable::Composable` module which will give you the necessary `\#>>`, `#<<`, and `#call` methods where you only need to implement the `#call` method.
|
458
|
+
|
459
|
+
== Development
|
460
|
+
|
461
|
+
To contribute, run:
|
462
|
+
|
463
|
+
[source,bash]
|
464
|
+
----
|
465
|
+
git clone https://github.com/bkuhlmann/pipeable
|
466
|
+
cd pipeable
|
467
|
+
bin/setup
|
468
|
+
----
|
469
|
+
|
470
|
+
You can also use the IRB console for direct access to all objects:
|
471
|
+
|
472
|
+
[source,bash]
|
473
|
+
----
|
474
|
+
bin/console
|
475
|
+
----
|
476
|
+
|
477
|
+
=== Architecture
|
478
|
+
|
479
|
+
The architecture of this gem is built on top of the following concepts and gems:
|
480
|
+
|
481
|
+
* {function_composition_link}: Made possible through the use of the `\#>>` and `#<<` methods on the link:https://rubyapi.org/3.1/o/method[Method] and link:https://rubyapi.org/3.1/o/proc[Proc] objects.
|
482
|
+
* {dry_container_link}: Allows related dependencies to be grouped together for injection as desired.
|
483
|
+
* {dry_monads_link}: Critical to ensuring the entire pipeline of steps adhere to the {railway_pattern_link} and leans heavily on the `Result` object.
|
484
|
+
* link:https://alchemists.io/projects/marameters[Marameters]: Through the use of the `.categorize` method, dynamic message passing is possible by inspecting the operation method's parameters.
|
485
|
+
|
486
|
+
=== Style Guide
|
487
|
+
|
488
|
+
* *Transactions*
|
489
|
+
** Use a single method (i.e. `#call`) which is public and adheres to the {command_pattern_link} so transactions can be piped together if desired.
|
490
|
+
* *Steps*
|
491
|
+
** Inherit from the `Abstract` class to gain monad, composition, and dependency behavior. This allows subclasses to have direct access to the base positional, keyword, and block arguments. These variables are prefixed with `base_*` in order to not conflict with subclasses which might only want to use non-prefixed variables for convenience.
|
492
|
+
** All filtered arguments -- in other words, the unused arguments -- need to be passed up to the superclass from the subclass (i.e. `super(*positionals, **keywords, &block)`). Doing so allows the superclass (i.e. `Abstract`) to provide access to `base_positionals`, `base_keywords`, and `base_block` for use if desired by the subclass.
|
493
|
+
** The `#call` method must define a single positional `result` parameter since a monad will be passed as an argument. Example: `def call(result) = # Implementation`.
|
494
|
+
** Each block within the `#call` method should use the `input` parameter to be consistent. More specific parameters like `argument` or `operation` should be used to improve readability when possible. Example: `def call(result) = result.bind { |input| # Implementation }`.
|
495
|
+
** Use implicit blocks sparingly. Most of the default steps shy away from using blocks because it can make the code more complex. Use private methods, custom steps, and/or separate transactions if the code becomes too complex because you might have a smaller object which needs extraction.
|
496
|
+
|
497
|
+
=== Debugging
|
498
|
+
|
499
|
+
If you need to debug (i.e. {debug_link}) your pipe, use a lambda. Example:
|
500
|
+
|
501
|
+
[source,ruby]
|
502
|
+
----
|
503
|
+
pipe data,
|
504
|
+
check(/Book.+Price/, :match?),
|
505
|
+
-> result { binding.break }, # Breakpoint
|
506
|
+
:parse
|
507
|
+
----
|
508
|
+
|
509
|
+
The above breakpoint will allow you inspect the result of the `#check` step and/or build a modified result for passing to the subsequent `:parse` method step.
|
510
|
+
|
511
|
+
=== Troubleshooting
|
512
|
+
|
513
|
+
The following might be of aid to as you implement your own transactions.
|
514
|
+
|
515
|
+
==== Type Errors
|
516
|
+
|
517
|
+
If you get a `TypeError: Step must be functionally composable and answer a monad`, it means:
|
518
|
+
|
519
|
+
. The step must be a `Proc`, `Method`, or some object which responds to `\#>>`, `#<<`, and `#call`.
|
520
|
+
. The step doesn't answer a result monad (i.e. `Success some_value` or `Failure some_value`).
|
521
|
+
|
522
|
+
==== No Method Errors
|
523
|
+
|
524
|
+
If you get a `NoMethodError: undefined method `success?` exception, this might mean that you forgot to add a comma after one of your steps. Example:
|
525
|
+
|
526
|
+
[source,ruby]
|
527
|
+
----
|
528
|
+
# Valid
|
529
|
+
pipe "https://www.wikipedia.org",
|
530
|
+
to(client, :get),
|
531
|
+
try(:parse, catch: HTTP::Error)
|
532
|
+
|
533
|
+
# Invalid
|
534
|
+
pipe "https://www.wikipedia.org",
|
535
|
+
to(client, :get) # Missing comma.
|
536
|
+
try(:parse, catch: HTTP::Error)
|
537
|
+
----
|
538
|
+
|
539
|
+
== Tests
|
540
|
+
|
541
|
+
To test, run:
|
542
|
+
|
543
|
+
[source,bash]
|
544
|
+
----
|
545
|
+
bin/rake
|
546
|
+
----
|
547
|
+
|
548
|
+
== Benchmarks
|
549
|
+
|
550
|
+
To view/compare performance, run:
|
551
|
+
|
552
|
+
[source,bash]
|
553
|
+
----
|
554
|
+
bin/benchmark
|
555
|
+
----
|
556
|
+
|
557
|
+
💡 You can view current benchmarks at the end of the above file if you don't want to manually run them.
|
558
|
+
|
559
|
+
== link:https://alchemists.io/policies/license[License]
|
560
|
+
|
561
|
+
== link:https://alchemists.io/policies/security[Security]
|
562
|
+
|
563
|
+
== link:https://alchemists.io/policies/code_of_conduct[Code of Conduct]
|
564
|
+
|
565
|
+
== link:https://alchemists.io/policies/contributions[Contributions]
|
566
|
+
|
567
|
+
== link:https://alchemists.io/projects/pipeable/versions[Versions]
|
568
|
+
|
569
|
+
== link:https://alchemists.io/community[Community]
|
570
|
+
|
571
|
+
== Credits
|
572
|
+
|
573
|
+
* Built with link:https://alchemists.io/projects/gemsmith[Gemsmith].
|
574
|
+
* Engineered by link:https://alchemists.io/team/brooke_kuhlmann[Brooke Kuhlmann].
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pipeable
|
4
|
+
# Allows objects to be functionally composable.
|
5
|
+
module Composable
|
6
|
+
def >>(other) = method(:call) >> other
|
7
|
+
|
8
|
+
def <<(other) = method(:call) << other
|
9
|
+
|
10
|
+
def call = fail NotImplementedError, "`#{self.class.name}##{__method__}` must be implemented."
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry/monads"
|
4
|
+
|
5
|
+
module Pipeable
|
6
|
+
# Provids low-level functionality processing a sequence of steps.
|
7
|
+
Pipe = lambda do |input, *steps|
|
8
|
+
fail ArgumentError, "Pipe must have at least one step." if steps.empty?
|
9
|
+
|
10
|
+
result = input.is_a?(Dry::Monads::Result) ? input : Dry::Monads::Success(input)
|
11
|
+
|
12
|
+
steps.reduce(&:>>).call result
|
13
|
+
rescue NoMethodError
|
14
|
+
raise TypeError, "Step must be functionally composable and answer a monad."
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry/monads"
|
4
|
+
require "refinements/array"
|
5
|
+
|
6
|
+
module Pipeable
|
7
|
+
# Allows an object to pipe steps together to composed a single result.
|
8
|
+
class Stepable < Module
|
9
|
+
include Dry::Monads[:result]
|
10
|
+
|
11
|
+
using Refinements::Array
|
12
|
+
|
13
|
+
def initialize steps = Steps::Container, pipe: Pipe
|
14
|
+
super()
|
15
|
+
@steps = steps
|
16
|
+
@pipe = pipe
|
17
|
+
@instance_module = Class.new(Module).new
|
18
|
+
end
|
19
|
+
|
20
|
+
def included descendant
|
21
|
+
super
|
22
|
+
define_pipe
|
23
|
+
define_steps
|
24
|
+
descendant.include instance_module
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
attr_reader :steps, :pipe, :instance_module
|
30
|
+
|
31
|
+
def define_pipe
|
32
|
+
local_pipe = pipe
|
33
|
+
|
34
|
+
instance_module.define_method :pipe do |input, *steps|
|
35
|
+
steps.each { |step| steps.supplant step, method(step) if step.is_a? Symbol }
|
36
|
+
local_pipe.call(input, *steps)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def define_steps
|
41
|
+
instance_module.class_exec steps do |container|
|
42
|
+
container.each_key do |name|
|
43
|
+
define_method name do |*positionals, **keywords, &block|
|
44
|
+
step = container[name]
|
45
|
+
step.is_a?(Proc) ? step : step.new(*positionals, **keywords, &block)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry/monads"
|
4
|
+
require "marameters"
|
5
|
+
|
6
|
+
module Pipeable
|
7
|
+
module Steps
|
8
|
+
# Provides a custom step blueprint.
|
9
|
+
class Abstract
|
10
|
+
include Dry::Monads[:result]
|
11
|
+
include Composable
|
12
|
+
|
13
|
+
def initialize *positionals, **keywords, &block
|
14
|
+
@base_positionals = positionals
|
15
|
+
@base_keywords = keywords
|
16
|
+
@base_block = block
|
17
|
+
@marameters = Marameters
|
18
|
+
end
|
19
|
+
|
20
|
+
protected
|
21
|
+
|
22
|
+
attr_reader :base_positionals, :base_keywords, :base_block, :marameters
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pipeable
|
4
|
+
module Steps
|
5
|
+
# Allows result to be messaged as a callable.
|
6
|
+
class As < Abstract
|
7
|
+
def call result
|
8
|
+
result.fmap { |operation| operation.public_send(*base_positionals, **base_keywords) }
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|