Multipipe: macros for multiple parameter piping (and, of course, redirection to different parameters)

There have been several nice libraries written with modified pipes, but with this one I decided to try and write something that works alongside the default pipe instead of replace it.

Multipipe provides two macros to collect parameters and then apply those parameters using the Elixir pipe. For example:

param(1, %{:a => "ok"})
  |> param(2, :a)
  |> useparams(Map.get)

The order the parameters are set doesn’t matter, and you can also pipe into the collection:

"a" |> String.to_atom
    |> param(2, _)
    |> param(1, %{:a => "ok"})
    |> useparams(Map.get)

A side effect is that you can pipe into any input:

param(2, :a) |> useparams(Map.get(%{:a => "ok"}))

or

param(1, %{:a => "ok"})
  |> param(3, "Key not found.")
  |> useparams(Map.get(:a))

The way it’s implemented is that the param collects the parameters into a map, which useparams uses to pipe the parameters into the function call.

I’ve also considered making a holdparams macro that holds the current parameters while another pipeline produces another value. It would work like:

value |> param(1, _)
      |> holdparams(stuff)
      |> holdparams(morestuff)
      |> mergeparams(2)
      |> useparams(func)

which would be equivalent to func(value, morestuff(stuff)). I decided against this because I don’t like the idea of merging so many pipelines into one long one, and didn’t want to encourage that sort of thing.

All suggestions, comments, criticisms, and ideas for improvement welcome. I know the way the macros are written can be improved. I wrestled a while with how they get expanded and doubt I found the best way to deal with it.

https://github.com/johnnyfeng/multipipe
It’s not on hex but I’d be happy to add it if wanted.

edit

3 Likes

For each of the examples you provide, can you show what this looks like with regular elixir? I don’t know what :: does or how |> (useparams Map.get) works.

1 Like

Loosely, within the param macro, i :: value means "assign value to parameter number i".

More technically, the macro param takes two values, a map and an i :: value statement (really their abstract syntax trees), and it adds the i => value pair to the map. If the initial map isn’t given, it defaults to the empty map. If we rewrite the first example in normal Elixir and expand the macros from first to last, it would look like:

(param 1 :: %{:a => "ok"})
  |> (param 2 :: :a)
  |> (useparams Map.get)
...
param(%{}, 1 :: %{:a => "ok"})
  |> param(2 :: a)
  |> useparams(Map.get)
...
%{1 => %{:a => "ok"}}
  |> param(2 :: a)
  |> useparams(Map.get)
...
param(%{1 => %{:a => "ok"}}, 2 :: a)
  |> useparams(Map.get)
...
%{1 => %{:a => "ok"}, 2 => :a}
  |> useparams(Map.get)
...
useparams(%{1 => %{:a => "ok"}, 2 => :a}, Map.get)

Then the macro useparams takes the map and the function, and for each i => value pair it pipes value as parameter i in the function call, so this finally expands to:

Map.get(%{:a => "ok"}, :a)

Does that make sense?

I forgot to mention, param replaces param(x, i :: _) with param(%{}, i :: x), so you can pipe a value into a parameter like in the second example. That example would expand as:

"a" |> String.to_atom
      |> param(2 :: _)
      |> param(1 :: %{:a => "ok"})
      |> useparams(Map.get)
...
param(String.to_atom("a"), 2 :: _)
      |> param(1 :: %{:a => "ok"})
      |> useparams(Map.get)
...
param(%{}, 2 :: String.to_atom("a"))
      |> param(1 :: %{:a => "ok"})
      |> useparams(Map.get)
...
%{2 => String.to_atom("a")}
      |> param(1 :: %{:a => "ok"})
      |> useparams(Map.get)
...
(as before, etc.)
...
Map.get(%{:a => "ok"}, String.to_atom("a"))

Now that I’m staring at it, the normal placement of parentheses seems much more readable than how I originally had it. Going to change the documentation and examples to use that syntax.

On even more reflection, I think using regular commas is better than using the special :: syntax in the param macro.

This is hopefully the final syntax change, but suggestions for improvement are always welcome.

Now Multipipe.param/3 is simply a macro version of Map.put/3 that builds a map at compile time, and useparams takes the map and a function and pipes the map values into the function call.

Added the macro as_param that simply redirects the default pipe to another parameter.

As an example:

def concat(x,y) when is_bitstring(x) and is_bitstring(y) do
  x <> y
end

"Hello" |> as_param(1, concat("World"))
# "HelloWorld"
"Hello" |> as_param(2, concat("World"))
# "WorldHello"
1 Like