pipeable 0.0.1 → 0.2.0

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.
data/README.adoc ADDED
@@ -0,0 +1,572 @@
1
+ :toc: macro
2
+ :toclevels: 5
3
+ :figure-caption!:
4
+
5
+ :command_pattern_link: link:https://alchemists.io/articles/command_pattern[Command Pattern]
6
+ :containable_link: link:https://alchemists.io/projects/containable[Containable]
7
+ :debug_link: link:https://github.com/ruby/debug[Debug]
8
+ :dry_monads_link: link:https://dry-rb.org/gems/dry-monads[Dry Monads]
9
+ :dry_schema_link: link:https://dry-rb.org/gems/dry-schema[Dry Schema]
10
+ :dry_validation_link: link:https://dry-rb.org/gems/dry-validation[Dry Validation]
11
+ :function_composition_link: link:https://alchemists.io/articles/ruby_function_composition[Function Composition]
12
+ :infusible_link: link:https://alchemists.io/projects/infusible[Infusible]
13
+ :railway_pattern_link: link:https://fsharpforfunandprofit.com/posts/recipe-part2[Railway Pattern]
14
+
15
+ = Pipeable
16
+
17
+ 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.
18
+
19
+ toc::[]
20
+
21
+ == Features
22
+
23
+ * Built atop of native {function_composition_link}.
24
+ * Adheres to the {railway_pattern_link}.
25
+ * Provides built-in and customizable domain-specific steps.
26
+ * Provides chainable _pipes_ which can be used to build more complex workflows.
27
+ * Compatible with the {containable_link}, {infusible_link}, and {dry_monads_link} gems.
28
+
29
+ == Requirements
30
+
31
+ . link:https://www.ruby-lang.org[Ruby].
32
+ . A strong understanding of {function_composition_link}.
33
+
34
+ == Setup
35
+
36
+ To install _with_ security, run:
37
+
38
+ [source,bash]
39
+ ----
40
+ # 💡 Skip this line if you already have the public certificate installed.
41
+ gem cert --add <(curl --compressed --location https://alchemists.io/gems.pem)
42
+ gem install pipeable --trust-policy HighSecurity
43
+ ----
44
+
45
+ To install _without_ security, run:
46
+
47
+ [source,bash]
48
+ ----
49
+ gem install pipeable
50
+ ----
51
+
52
+ You can also add the gem directly to your project:
53
+
54
+ [source,bash]
55
+ ----
56
+ bundle add pipeable
57
+ ----
58
+
59
+ Once the gem is installed, you only need to require it:
60
+
61
+ [source,ruby]
62
+ ----
63
+ require "pipeable"
64
+ ----
65
+
66
+ == Usage
67
+
68
+ You can turn any object into a _transaction_ by requiring and including this gem as follows:
69
+
70
+ [source,ruby]
71
+ ----
72
+ require "csv"
73
+ require "pipeable"
74
+
75
+ class Demo
76
+ include Pipeable
77
+
78
+ def initialize client: CSV
79
+ @client = client
80
+ end
81
+
82
+ def call data
83
+ pipe data,
84
+ check(/Book.+Price/, :match?),
85
+ :parse,
86
+ map { |item| "#{item[:book]}: #{item[:price]}" }
87
+ end
88
+
89
+ private
90
+
91
+ attr_reader :client
92
+
93
+ def parse result
94
+ result.fmap do |data|
95
+ client.instance(data, headers: true, header_converters: proc { |key| key.downcase.to_sym })
96
+ .to_a
97
+ .map(&:to_h)
98
+ end
99
+ end
100
+ end
101
+ ----
102
+
103
+ 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}.
104
+
105
+ To execute the above example, you'd only need to pass CSV content to it:
106
+
107
+ [source,ruby]
108
+ ----
109
+ Demo.new.call <<~CSV
110
+ Book,Author,Price,At
111
+ Mystics,urGoh,10.50,2022-01-01
112
+ Skeksis,skekSil,20.75,2022-02-13
113
+ CSV
114
+ ----
115
+
116
+ The computed result is a success with each book listing a price:
117
+
118
+ ....
119
+ Success ["Mystics: 10.50", "Skeksis: 20.75"]
120
+ ....
121
+
122
+ === Pipe
123
+
124
+ 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:
125
+
126
+ [source,ruby]
127
+ ----
128
+ pipe(input, *steps)
129
+ ----
130
+
131
+ 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}.
132
+
133
+ Behind the scenes, the `#pipe` method is syntactic sugar on top of {function_composition_link} which means if this code were to be rewritten:
134
+
135
+ [source,ruby]
136
+ ----
137
+ pipe csv,
138
+ check(/Book.+Price/, :match?),
139
+ :parse,
140
+ map { |item| "#{item[:book]}: #{item[:price]}" }
141
+ ----
142
+
143
+ Then the above would look like this using native Ruby:
144
+
145
+ [source,ruby]
146
+ ----
147
+ (
148
+ check(/Book.+Price/, :match?) >>
149
+ method(:parse) >>
150
+ map { |item| "#{item[:book]}: #{item[:price]}" }
151
+ ).call Success(csv)
152
+ ----
153
+
154
+ 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.
155
+
156
+ === Steps
157
+
158
+ 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.
159
+
160
+ ==== Basic
161
+
162
+ The following are the basic (default) steps for building for more advanced functionality.
163
+
164
+ ===== As
165
+
166
+ Allows you to message the input as different output. Example:
167
+
168
+ [source,ruby]
169
+ ----
170
+ pipe :a, as(:inspect) # Success ":a"
171
+ pipe %i[a b c], as(:dig, 1) # Success :b
172
+ pipe Failure("Danger!"), as(:inspect) # Failure "Danger!"
173
+ ----
174
+
175
+ ===== Bind
176
+
177
+ 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:
178
+
179
+ [source,ruby]
180
+ ----
181
+ pipe %i[a b c], bind { |input| Success input.join("-") } # Success "a-b-c"
182
+ pipe %i[a b c], bind { |input| Failure input } # Failure [:a, :b, :c]
183
+ pipe Failure("Danger!"), bind { |input| Success input.join("-") } # Failure "Danger!"
184
+ ----
185
+
186
+ ===== Check
187
+
188
+ 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:
189
+
190
+ [source,ruby]
191
+ ----
192
+ pipe :a, check(%i[a b], :include?) # Success :a
193
+ pipe :a, check(%i[b c], :include?) # Failure :a
194
+ pipe Failure("Danger!"), check(%i[a b], :include?) # Failure "Danger!"
195
+ ----
196
+
197
+ ===== Fmap
198
+
199
+ 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:
200
+
201
+ [source,ruby]
202
+ ----
203
+ pipe %i[a b c], fmap { |input| input.join "-" } # Success "a-b-c"
204
+ pipe Failure("Danger!"), fmap { |input| input.join "-" } # Failure "Danger!"
205
+ ----
206
+
207
+ ===== Insert
208
+
209
+ 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:
210
+
211
+ [source,ruby]
212
+ ----
213
+ pipe :a, insert(:b) # Success [:a, :b]
214
+ pipe :a, insert(:b, at: 0) # Success [:b, :a]
215
+ pipe %i[a c], insert(:b, at: 1) # Success [:a, :b, :c]
216
+ pipe Failure("Danger!"), insert(:b) # Failure "Danger!"
217
+ ----
218
+
219
+ ===== Map
220
+
221
+ Allows you to map over an enumerable and wraps native link:https://rubyapi.org/o/enumerable#method-i-map[Enumerable#map] functionality.
222
+
223
+ [source,ruby]
224
+ ----
225
+ pipe %i[a b c], map(&:inspect) # Success [":a", ":b", ":c"]
226
+ pipe Failure("Danger!"), map(&:inspect) # Failure "Danger!"
227
+ ----
228
+
229
+ ===== Merge
230
+
231
+ 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:
232
+
233
+ [source,ruby]
234
+ ----
235
+ pipe({a: 1}, merge(b: 2)) # Success {a: 1, b: 2}
236
+ pipe "test", merge(b: 2) # Success {step: "test", b: 2}
237
+ pipe "test", merge(as: :a, b: 2) # Success {a: "test", b: 2}
238
+ pipe Failure("Danger!"), merge(b: 2) # Failure "Danger!"
239
+ ----
240
+
241
+ ===== Orr
242
+
243
+ 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.
244
+
245
+ ℹ️ Syntactically, `or` can't be used for this step since `or` is a native Ruby keyword so `orr` is used instead.
246
+
247
+ Example:
248
+
249
+ [source,ruby]
250
+ ----
251
+ pipe %i[a b c], orr { |input| Success input.join("-") } # Success [:a, :b, :c]
252
+ pipe Failure("Danger!"), orr { Success "Resolved" } # Success "Resolved"
253
+ pipe Failure("Danger!"), orr { |input| Failure "Big #{input}" } # Failure "Big Danger!"
254
+ ----
255
+
256
+ ===== Tee
257
+
258
+ 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:
259
+
260
+ [source,ruby]
261
+ ----
262
+ pipe "test", tee(Kernel, :puts, "Example.")
263
+
264
+ # Example.
265
+ # Success "test"
266
+
267
+ pipe Failure("Danger!"), tee(Kernel, :puts, "Example.")
268
+
269
+ # Example.
270
+ # Failure "Danger!"
271
+ ----
272
+
273
+ ===== To
274
+
275
+ 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:
276
+
277
+ [source,ruby]
278
+ ----
279
+ Model = Struct.new :label, keyword_init: true do
280
+ include Dry::Monads[:result]
281
+
282
+ def self.for(...) = Success new(...)
283
+ end
284
+
285
+ pipe({label: "Test"}, to(Model, :for)) # Success #<struct Model label="Test">
286
+ pipe Failure("Danger!"), to(Model, :for) # Failure "Danger!"
287
+ ----
288
+
289
+ ===== Try
290
+
291
+ Allows you to try an operation which may fail while catching the exception as a failure for further processing. Example:
292
+
293
+ [source,ruby]
294
+ ----
295
+ pipe "test", try(:to_json, catch: JSON::ParserError) # Success "\"test\""
296
+ pipe "test", try(:invalid, catch: NoMethodError) # Failure "undefined method..."
297
+ pipe Failure("Danger!"), try(:to_json, catch: JSON::ParserError) # Failure "Danger!"
298
+ ----
299
+
300
+ ===== Use
301
+
302
+ 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.
303
+
304
+ [source,ruby]
305
+ ----
306
+ function = -> input { Success input * 3 }
307
+
308
+ pipe 3, use(function) # Success 9
309
+ pipe Failure("Danger!"), use(function) # Failure "Danger!"
310
+ ----
311
+
312
+ ===== Validate
313
+
314
+ 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.
315
+
316
+ 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.
317
+
318
+ [source,ruby]
319
+ ----
320
+ schema = Dry::Schema.Params { required(:label).filled :string }
321
+
322
+ pipe({label: "Test"}, validate(schema)) # Success label: "Test"
323
+ pipe({label: "Test"}, validate(schema, as: nil)) # Success #<Dry::Schema::Result{:label=>"Test"} errors={} path=[]>
324
+ pipe Failure("Danger!"), validate(schema) # Failure "Danger!"
325
+ ----
326
+
327
+ ==== Advanced
328
+
329
+ Several options are available should you need to advance beyond the basic steps. Each is described in detail below.
330
+
331
+ ===== Procs
332
+
333
+ You can always use a `Proc` as a custom step. Example:
334
+
335
+ [source,ruby]
336
+ ----
337
+ include Pipeable
338
+ include Dry::Monads[:result]
339
+
340
+ pipe :a,
341
+ insert(:b),
342
+ proc { Success "input_ignored" },
343
+ as(:to_sym)
344
+
345
+ # Yields: Success :input_ignored
346
+ ----
347
+
348
+ ===== Lambdas
349
+
350
+ In addition to procs, lambdas can be used too. Example:
351
+
352
+ [source,ruby]
353
+ ----
354
+ include Pipeable
355
+
356
+ pipe :a,
357
+ insert(:b),
358
+ -> result { result.fmap { |input| input.join "_" } },
359
+ as(:to_sym)
360
+
361
+ # Yields: Success :a_b
362
+ ----
363
+
364
+ ===== Methods
365
+
366
+ Methods -- in addition to procs and lambdas -- are the _preferred_ way to add custom steps due to the concise syntax. Example:
367
+
368
+ [source,ruby]
369
+ ----
370
+ class Demo
371
+ include Pipeable
372
+
373
+ def call input
374
+ pipe :a,
375
+ insert(:b),
376
+ :join,
377
+ as(:to_sym)
378
+ end
379
+
380
+ private
381
+
382
+ def join(result) = result.fmap { |input| input.join "_" }
383
+ end
384
+
385
+ Demo.new.call :a # Yields: Success :a_b
386
+ ----
387
+
388
+ 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.
389
+
390
+ ===== Custom
391
+
392
+ If you'd like to define permanent and reusable steps, you can register a custom step which requires you to:
393
+
394
+ . Define a custom step as a new class.
395
+ . Register your custom step along side the existing default steps.
396
+
397
+ Here's what this would look like:
398
+
399
+ [source,ruby]
400
+ ----
401
+ module CustomSteps
402
+ class Join < Pipeable::Steps::Abstract
403
+ def initialize(delimiter = "_", **)
404
+ super(**)
405
+ @delimiter = delimiter
406
+ end
407
+
408
+ def call(result) = result.fmap { |input| input.join delimiter }
409
+
410
+ private
411
+
412
+ attr_reader :delimiter
413
+ end
414
+ end
415
+
416
+ Pipeable::Steps::Container.register :join, CustomSteps::Join
417
+
418
+ include Pipeable
419
+
420
+ pipe :a, insert(:b), join, as(:to_sym)
421
+ # Yields: Success :a_b
422
+
423
+ pipe :a, insert(:b), join(""), as(:to_sym)
424
+ # Yields: Success :ab
425
+ ----
426
+
427
+ === Containers
428
+
429
+ 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 `.[]` when including pipeable behavior. Example:
430
+
431
+ [source,ruby]
432
+ ----
433
+ require "containable"
434
+
435
+ module CustomContainer
436
+ extend Containable
437
+
438
+ register :echo, -> result { result }
439
+ register :insert, Pipeable::Steps::Insert
440
+ end
441
+
442
+ include Pipeable[CustomContainer]
443
+
444
+ pipe :a, echo, insert(:b)
445
+
446
+ # Yields: Success [:a, :b]
447
+ ----
448
+
449
+ 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 `.[]` (i.e. `include Pipeable[CustomContainer]`).
450
+
451
+ Whether you use default, custom, or hybrid steps, you have maximum flexibility using this approach.
452
+
453
+ === Composition
454
+
455
+ 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.
456
+
457
+ == Development
458
+
459
+ To contribute, run:
460
+
461
+ [source,bash]
462
+ ----
463
+ git clone https://github.com/bkuhlmann/pipeable
464
+ cd pipeable
465
+ bin/setup
466
+ ----
467
+
468
+ You can also use the IRB console for direct access to all objects:
469
+
470
+ [source,bash]
471
+ ----
472
+ bin/console
473
+ ----
474
+
475
+ === Architecture
476
+
477
+ The architecture of this gem is built on top of the following concepts and gems:
478
+
479
+ * {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.
480
+ * {containable_link}: Allows related dependencies to be grouped together for injection as desired.
481
+ * {dry_monads_link}: Critical to ensuring the entire pipeline of steps adhere to the {railway_pattern_link} and leans heavily on the `Result` object.
482
+ * 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.
483
+
484
+ === Style Guide
485
+
486
+ * *Transactions*
487
+ ** 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.
488
+ * *Steps*
489
+ ** 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.
490
+ ** 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.
491
+ ** The `#call` method must define a single positional `result` parameter since a monad will be passed as an argument. Example: `def call(result) = # Implementation`.
492
+ ** 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 }`.
493
+ ** 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.
494
+
495
+ === Debugging
496
+
497
+ If you need to debug (i.e. {debug_link}) your pipe, use a lambda. Example:
498
+
499
+ [source,ruby]
500
+ ----
501
+ pipe data,
502
+ check(/Book.+Price/, :match?),
503
+ -> result { binding.break }, # Breakpoint
504
+ :parse
505
+ ----
506
+
507
+ 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.
508
+
509
+ === Troubleshooting
510
+
511
+ The following might be of aid to as you implement your own transactions.
512
+
513
+ ==== Type Errors
514
+
515
+ If you get a `TypeError: Step must be functionally composable and answer a monad`, it means:
516
+
517
+ . The step must be a `Proc`, `Method`, or some object which responds to `\#>>`, `#<<`, and `#call`.
518
+ . The step doesn't answer a result monad (i.e. `Success some_value` or `Failure some_value`).
519
+
520
+ ==== No Method Errors
521
+
522
+ 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:
523
+
524
+ [source,ruby]
525
+ ----
526
+ # Valid
527
+ pipe "https://www.wikipedia.org",
528
+ to(client, :get),
529
+ try(:parse, catch: HTTP::Error)
530
+
531
+ # Invalid
532
+ pipe "https://www.wikipedia.org",
533
+ to(client, :get) # Missing comma.
534
+ try(:parse, catch: HTTP::Error)
535
+ ----
536
+
537
+ == Tests
538
+
539
+ To test, run:
540
+
541
+ [source,bash]
542
+ ----
543
+ bin/rake
544
+ ----
545
+
546
+ == Benchmarks
547
+
548
+ To view/compare performance, run:
549
+
550
+ [source,bash]
551
+ ----
552
+ bin/benchmark
553
+ ----
554
+
555
+ 💡 You can view current benchmarks at the end of the above file if you don't want to manually run them.
556
+
557
+ == link:https://alchemists.io/policies/license[License]
558
+
559
+ == link:https://alchemists.io/policies/security[Security]
560
+
561
+ == link:https://alchemists.io/policies/code_of_conduct[Code of Conduct]
562
+
563
+ == link:https://alchemists.io/policies/contributions[Contributions]
564
+
565
+ == link:https://alchemists.io/projects/pipeable/versions[Versions]
566
+
567
+ == link:https://alchemists.io/community[Community]
568
+
569
+ == Credits
570
+
571
+ * Built with link:https://alchemists.io/projects/gemsmith[Gemsmith].
572
+ * 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,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+ require "refinements/array"
5
+
6
+ module Pipeable
7
+ # Defines the pipe and and associated step methods for an object.
8
+ class Definer < Module
9
+ include Dry::Monads[:result]
10
+
11
+ using Refinements::Array
12
+
13
+ def initialize container = Steps::Container, pipe: Pipe
14
+ super()
15
+ @container = container
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 :container, :pipe, :instance_module
30
+
31
+ def define_pipe pipeline = pipe
32
+ instance_module.define_method :pipe do |input, *steps|
33
+ steps.each { |step| steps.supplant step, method(step) if step.is_a? Symbol }
34
+ pipeline.call(input, *steps)
35
+ end
36
+ end
37
+
38
+ def define_steps
39
+ instance_module.class_exec container do |dependencies|
40
+ dependencies.each_key do |name|
41
+ define_method name do |*positionals, **keywords, &block|
42
+ step = dependencies[name]
43
+ step.is_a?(Proc) ? step : step.new(*positionals, **keywords, &block)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ 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,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