lautis/piperator

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.

alaz commented

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.

I made a rather quick effort to implement the DSL in #7.