Small useful macro |> case_continue

I just wanted to share with you a small macro that has cleaned up many many lines of code for me.

Basically in my contexts I had this kind of code everywhere

      |> Repo.transaction()
      |> case do
        {:ok, %{goal: goal}} = result ->
          PubSub.broadcast_goal_created(user, goal)
          result

        error ->
          error
      end

A possible solution would be to add maybe functions

      |> Repo.transaction()
      |> maybe_broadcast_goal_created(user)

..

def maybe_broadcast_goal_created({:ok, %{goal: goal}} = result, user) do
  PubSub.broadcast_goal_created(user, goal)
  result
end

def maybe_broadcast_goal_created(result, user), do: result

Which is not ideal since I’d have to create one pair for each function in context.

Macros to the rescue!

Inspired by how IO.inspect prints and keeps moving, I created case_continue, which runs case, ignores it’s result and continues the pipe as if it was never there.

defmodule Timetask.Helpers.Macros do
  defmacro case_continue(prev, do: block) do
    quote do
      case unquote(prev) do
        unquote(block ++ [{:->, [], [[{:_, [], nil}], nil]}])
      end

      unquote(prev)
    end
  end
end

Now I can write it like this.

      |> Repo.transaction()
      |> case_continue do
        {:ok, %{goal: goal}} -> PubSub.broadcast_goal_created(user, goal)
      end

Much nicer huh? What do you think? Do you like the name? I also had in mind case_bridge or case_skip.

1 Like
  1. Piping into case lately became a subject for debates :smiling_imp: (see don’t pipe into case statements)

  2. Macros are awesome! …mostly for stuff I do on my own :neutral_face:
    Especially adding up to the language syntax meant to be commonly used across the project might not be obvious to the team who work with the code.

    Macros should only be used as a last resort. Remember that explicit is better than implicit . Clear code is better than concise code.

    Macros - The Elixir programming language


Whenever I notice case of just two branches one of which just returns the input - I think of with

    result =
      ...
      |> Repo.transaction()

    with {:ok, %{goal: goal}} <- result do
      PubSub.broadcast_goal_created(user, goal)
      result
    end

With your macro we can handle more than just one happy path, though.

3 Likes

I don’t find the error -> boilerplate that annoying, personally. I always prefer to unpack results (both successand error) from passing Ecto.Multi to Repo.transaction as close to the transaction as possible, because otherwise operation names become an accidental part of the function’s interface.

I’m also not sure how the above macro accomplishes the task - it seems like a result other than {:ok, %{goal: _}} will fail to match in the generated case statement.

3 Likes

Interesting, is this like a tab vs spaces kinda thing? hehe

I’ll check it out.

Yup, bug found! Too quick to publish (solved above) :stuck_out_tongue:

Good point. You could still unpack the result in a later case do to give shape to errors.

      |> Repo.transaction()
      |> case_continue do
        {:ok, %{goal: goal}} -> PubSub.broadcast_goal_created(user, goal)
      end
      |> case do
        {:error, :validate_uniqueness, _, _} -> {:error, "not good"}
        result -> result
      end

or even

      |> Repo.transaction()
      |> case_continue do
        {:ok, %{goal: goal}} -> PubSub.broadcast_goal_created(user, goal)
      end
      |> shape_create_goals_response()

To me it feels natural to have a separate step in the pipeline dedicated to call external services. But that’s just me. It’s definitely clearer when dealing with Repo's update, insert or delete, where Ecto’s interface is usually the function’s interface → {:ok, struct} & {:error, Changeset}.

1 Like

It depends. Sometimes if “call to external service” fails we would want to rollback the transaction =)) can’t come up with a reasonable example from top of my head why would we took a transaction before external service call, but I have a feeling that I faced that in the past. :slight_smile:

1 Like

Using the then/2 macro introduced in elixir 1.12, you can do:

      |> Repo.transaction()
      |> then(fn 
        {:ok, %{goal: goal}} = result ->
          PubSub.broadcast_goal_created(user, goal)
          result

        error ->
          error
      end)
2 Likes

Where is the debate? This just says: dont do it!

2 Likes

Am i missing something or would one possible solution to define something like Maybe.map that “hides” that case expression away in a generic way (or instead Result.map depending on your context, but that’s not really important)? Maybe and Result modules are common in lots of languages nowadays, so i hope it’s clear what i mean with that. That way you don’t have to define a helper for every situation.

Sorry, should have mentioned. Some discussions can be found here and there… for example in threads in twitter like this one. Or in some threads in comments in HackerNews [1][2]

1 Like

There is no case for not piping into case in the links you provided.

Great observation about “maybe” as a name. I actually renamed the macro to maybe_case.

I fail to grasp what you are trying to argue thou. Could you provide some high level code?

Sure, sorry! I’m thinking something like

defmodule Result do
  def tap(result, tapper) do
    case result do
      {:ok, value} = ok ->
        tapper.(value)
        ok

      {:error, _} = error ->
        error
    end
  end
end

# Usage

|> Repo.transaction()
|> Result.tap(fn %{goal: goal} -> PubSub.broadcast_goal_created(user, goal) end)

I realized my earlier message was wrong because i thought you were mapping the current value but it looks you are only interested in “doing something with the value but keeping it as is”? That’s why i named it tap now like in the standard library.

Now this module should be able to handle every data in the shape {:ok, data} | {:error, info} and allow you to run some sideeffects in the case of {:ok, _} via Result.tap/2.

To be honest: If that is now better than your macro? Not really sure. But it’s a function (and definitively very functional, see lots of other languages :smiley:). And also there might be libraries definining something like the Result module for you and it’s a bit more “standard”.

(Also: I wasn’t able to test this code, i hope i didn’t do a really stupid mistake somewhere…)

1 Like
defmodule Timetask.Helpers.Macros do
  defmacro case_continue(prev, do: block) do
    quote do
      case unquote(prev) do
        unquote(block ++ [{:->, [], [[{:_, [], nil}], nil]}])
      end

      unquote(prev)
    end
  end
end

I must be missing a fancy optimization: how does this avoid running the code in prev twice?

Concrete example:

%Foo{name: "nothing in particular"}
|> Repo.insert()
|> case_continue do
  {:ok, foo} -> IO.inspect(foo)
end

naively expanding the macro results in this code:

%Foo{name: "nothing in particular"}
|> Repo.insert()
|> case do
  {:ok, foo} -> IO.inspect(foo)
  _ -> nil
end

%Foo{name: "nothing in particular"}
|> Repo.insert()

The macro should probably bind the inbound to a variable then case on it.

I would also suggest “match_or_continue” as a name because I think that’s a better name since you inject a default path

Another piping tool I’ve always wanted is some sort of “then if” statement.

I don’t lol. I was just struggling with this. It was a surprise to see my pipe going back an forwards and somehow landing on it’s feet at the end. The test that I added to see the failure, was exactly what you mention, i.e is count == 1?

Taken from the docs: :bind_quoted can be used in many cases and is seen as good practice, not only because it helps prevent us from running into common mistakes :face_with_head_bandage::sweat_smile:

I can’t seem to fix it thou, I feel like I’m 95% there, but gotta go now. Happy holidays to everyone helping me out here :grinning::christmas_tree::santa:t2:

defmodule Timetask.Helpers.Macros do
  defmacro maybe_case(prev, do: block) do
    quote bind_quoted: [prev: prev] do
      case prev do
        unquote(block ++ [{:->, [], [[{:_, [], nil}], nil]}])
      end

      prev
    end
  end
end

unfortunately, using bind_quoted disables the use of unquotes. So you might have to do this:

  defmacro maybe_case(prev, do: block) do
    quote do
      prev = unquote(prev)
      case prev do
        unquote(block ++ [{:->, [], [[{:_, [], nil}], nil]}])
      end
    end
  end
1 Like