Conditionally appliable pipes
Closed this issue · 7 comments
As of currently there's no nice way to conditionally omit a pipe from a chain of pipes under certain circumstances. The best we can do is either:
Clearest, but super annoying to have to declare extra variable. Especially with lengthier real world use cases you don't want to resort to this. Performance wise this is the best as the extra pipe is omitted altogether.
pipes = Pipe.wrap([45,5])
.pipe(Pipe1)
.pipe(Pipe2)
.pipe(Pipe3)
pipes = pipes.pipe(OptionalPipe) if should_apply?
pipes = pipes.pipe(Cleanup.new(stuff))
.pipe(SendResults)
...or perhaps the best current approach is to switch the pipe to a simple pass through pipe which does nothing.. however, an entirely unnecessary extra pipe is added to the pipeline (though the perf implications are probably negligible) and the syntax is a bit ugly:
Pipe.wrap([45,5])
.pipe(Pipe1)
.pipe(Pipe2)
.pipe(Pipe3)
.pipe(should_apply? ? OptionalPipe : ->(a){a})
.pipe(Cleanup.new(stuff))
.pipe(SendResults)
Perhaps the worst of all (though sometimes makes sense) is the solution where the decision to really do this processing is pushed inside the Pipeline, so that we can have clean pipeline build chain, but then in order to know if e.g. Cleanup is really gonna be applied to the pipeline in question you actually need to dive inside it:
class Cleanup
def initialize(stuff)
@stuff = stuff
end
def call(enumerable)
return enumerable unless @stuff.some_condition_applies?
do_cleanup(enumerable, @stuff)
end
#...
end
What we're missing imho is constraint options like rails validations and many other similar things have:
Pipe.wrap([45,5])
.pipe(Pipe1)
.pipe(Pipe2)
.pipe(Pipe3)
.pipe(OptionalPipe, if: should_apply?)
.pipe(Cleanup.new(stuff), if: stuff.some_condition_applies?)
.pipe(SendResults)
To add this sort of functionality I'm experimenting with this sort of thing:
module OmittablePipes
def pipe(other, options={})
if { if: true }.merge(options)[:if]
super(other)
else
self
end
end
end
Piperator::Pipeline.prepend(OmittablePipes)
Could also make a PR.
An FP kind of syntax is also possible. Some NodeJS libraries prefer this: gulpif, detour-stream (though loosely inspired by "gulp-if").
Applied to Ruby this syntax could look like
Pipe.wrap([1,2,3,4])
.pipe(Pipe.opt(Pipe1, if: should_apply?))
.pipe(Pipe.opt(Pipe2, unless: should_apply?))
...
This has a significant benefit that pipe
method does not need to be modified at all.
In Ruby 2.6 and later, you could use then
(or yield_self
in 2.5) to avoid the extra pipe. Syntax is not any nicer, though.
Pipe.wrap([45,5])
.pipe(Pipe1)
.pipe(Pipe2)
.pipe(Pipe3)
.then { |pipe| should_apply? ? OptionalPipe : pipe }
.pipe(Cleanup.new(stuff))
.pipe(SendResults)
The wrapper for optional steps suggested by @alaz does sound nice. Potentially one could implement other such functions if needed, and they can be composed.
If I'm not mistaken the exact syntax would be:
Pipe.wrap([45,5])
.pipe(Pipe1)
.pipe(Pipe2)
.pipe(Pipe3)
.pipe(OptionalPipe, if: should_apply?)
.pipe(should_apply? ? OptionalPipe : ->(a){a})
.pipe(Pipe.opt(OptionalPipe, if: should_apply?))
.then { |pipe| should_apply? ? pipe.pipe(OptionalPipe) : pipe }
.yield_self { |pipe| should_apply? ? pipe.pipe(OptionalPipe) : pipe }
.pipe(Cleanup.new(stuff))
.pipe(SendResults)
It's not impossibly ugly but still very verbose and cluttety.. +26 chars (for Ruby 2.5.x even more) compared to OmittablePipes
module. I feel this sort of thing should have a DSL which makes it as compact as possible to do. With this much clutter you're very quickly driven to multiline pipe declaration and I don't like it.
Regarding what @alaz commented I don't think it's a good Idea here.
Firstly, I'm here trying to convince @lautis to include this feature as a core functionality to the piperator
. And there's no reason why the author of this gem couldn't add this options parameter to pipe
method if he wants to include the feature. He ofc has the option to choose to keep his gem UNIX style as minimalistic as possible and reject this idea. I like my gems with a bit of meat around the bones, but that's just me.
Secondly, it might make sense to go that route if I was contemplating with the idea that I'd publish a gem pioerator-if
which would extend the piperator
gem, but I'm certainly not. I don't feel that thin feature warrants a separate gem. And even if I was it's imho ugly cluttety unruby like approach. It also has the disadvantage that Pipe.opt
would have to return a valid pipeable thing ->(a){a})
or the optional pipe i.e. it would be just "syntactic sugar" on top of the .pipe(should_apply? ? OptionalPipe : ->(a){a})
, but actually 2 chars longer and not really much nicer looking. Unlike with JS in Ruby land we have the advantage of features like prepend
and using them gives much more natural, ergonomic and Ruby like API/DSL for this feature. So if this is not going to become a core piperator features then and we go with the local mixin / monkey patch extension approach then the current OmittablePipes
approach is imho the only viable. Not only it has the nicest and shortest syntax it also actually omits the unnecessary pipe from the pipeline instead of substituting it with an unnecessary pass through pipeline.
Now if we have some deep paranoia about changing the pipe
method behaviour, another acceptable approach that I can think of would be to introduce another method pipe_if
. This could be implemented identically to OmittablePipes
or with simple truthy value as second param:
.pipe_if(OptionalPipe, if: should_apply?)
.pipe_if(OptionalPipe, should_apply?)
The latter has that downside that then if we would like to also introduce e.g. unless
then we'd again need to introduce pipe_unless
method and somehow I'm just not a huge fan of that. However, I'm ok with either one of these, but still thinking the original OmittablePipes
approach is the best.
Bonus..
Piperator could also just ignore/strip falsy pipes from the pipeline and then we could just
Pipe.wrap([45,5])
.pipe(Pipe1)
.pipe(Pipe2)
.pipe(Pipe3)
.pipe(should_apply? && OptionalPipe)
.pipe(Cleanup.new(stuff))
.pipe(SendResults)
This is in fact the shortest variant, but it is a bit dangerous.. in case some pipe building method would have bug causing it to unexpectedly return nil then instead of erroring, we'd just skip this step from the pipeline making the error harder to spot.
With if
and unless
parameters, my concerns are mainly about the composition: is there hypothetically an optional that would cause ambiguity? With function composition, we have e.g. precedence defined explicitly.
I'd be more interested in a pure DSL functionality, as that would allow using any Ruby program to modify the pipeline using syntax and semantics familiar to any Ruby developer.
For example,
Piperator.pipeline do
wrap [45, 5]
pipe Pipe1
pipe Pipe2
pipe Pipe3
pipe OptionalPipe if should_apply?
pipe Cleanup.new(stuff)
pipe SendResults
end
Yeah, that's another idea and produces the most beautiful DSL but requires a bit more effort and all the existing pipelines would need to be changed to this DSL format (if they wish to use this if semantics), but I'm all for it.