pipeable 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipeable
4
+ module Steps
5
+ # Wraps Dry Monads `#bind` method as a step.
6
+ class Bind < Abstract
7
+ def call(result) = result.bind { |input| base_block.call input }
8
+ end
9
+ end
10
+ end