This week I didn't have any mind-blowing revelations, but those are like unicorns, I'm fortunate when I come across them, but I know that it wont happen all the time. I did -through a colleague's PR comment- discover another Elixir function that will make my future life a little bit easier, Enum.any?/2
.
I was back in the world of custom validations, and they were getting more deeply nested than I liked, but I didn't see any way to make them more concise.
There's a lot going on here, but let's focus on the case statements that are being executed. With this particular validation the error shown to the user is the same in the case of all the false
s, in hindsight that's a clue that this could be cleaned up.
When it's all said and done I need to check if total_merch_rev
minus total_group_costs
equals net_revenue_shared
. But the fields coming in are not in the exact same shape, and 0 != 0.00. I first have to explicitly check if the fields are zero, and if not, then check if total_merch_rev
minus total_group_costs
equals net_revenue_shared
?
That logic would look something like this:
This is where Enum.any?/2
comes into play, as it invokes the function for each item in the collection returning true
if at least one invocation returns true
, and false
otherwise. Basically both of those chunks of logic can be dropped into that function, and if either return true, the changeset would be returned, and if either return false the error would be returned. So, the refactor would look something like this:
The rev_minus_cost == @zero_with_decimal && net_rev == @zero_with_decimal
line is equivalent to:
And, the rev_minus_cost == net_rev
line covers this case:
But, as I write this Im seeing how this can be refactored further and not even use Enum.any?/2
! Basically, the problem I was originally having was how the data was coming in, versus the result of the mathematical logic. Again, it all came down to the fact that 0 != 0.00. But by rounding both the total_merch_rev
minus total_group_costs
result and net_revenue_shared
data to the hundredth place, I'm essentially normalizing the data, and it can be compared to itself directly, even if the logic result or field is 0
.
So, the result of that refactor is this:
Well, I have to say, this is pretty awesome. While revisiting past work to write about a function, I see a way to refactor that code even further while writing about it, and ultimately refactor it in a way that it doesn't even need the function that the post is highlighting. It's great that deeply nested logic can be cleaned up so efficiently with Enum.any?/2
(along with the related function Enum.all?/2
), but I'm actually pretty happy that I was able to removed the need for it in this particular example.
This post is part of an ongoing This Week I Learned series. I welcome any critique, feedback, or suggestions in the comments.
Top comments (3)
Nice post, Noel!
I, too, have seen Elixir code that's gone awry with a bunch of nested conditions. It's usually a code smell whether we're operating in a functional programming or not.
Enum.any?
def looks like it helps in flattening. Another thing that I've found useful, and maybe you will as well iswith
special form. I rely it on quite a bit when the pipe operator is not applicable, because the functions all return an ok/error-tuple (i.e.{:ok, _}
/{:error, _}
) or don't handle them. I love Drew Olsen's post on the topic hereAnd lastly, I think you spotted what part of the issue was -- ensuring that the data-types match. Dealing with numbers and money can be hairy business. I've used the the money library in the past and actually just included in a new project that I started - github.com/elixirmoney/money. I typically also perform all data clean-up in the controller before they ever enter any business domain code (which also includes validations). I ensure that maps are converted to structs (never handling data that isn't well-formed and no maps with string keys unless its metadata), decimals that are supposed to represent money to currency structs (via the money library) all before pushing them through changeset validations. I'll let the consumer/client know that their request is malformed and return
4xx
if it isn't structured correctly. By the time it reaches the changeset validations, the datatypes should be correct and I can run validations without performing data-type transformation from one type to another just to compare them. I'm free to just just validate whether the values are valid or not.Hey David,
Thanks for pointing me to the
with
expression. I actually wrote a post of my own here. I am by no means an expert, but just writing the post helped me get a better handle on all the possibilities.A big caveat we have in the project I'm working on is when/if to do the rounding. I ended up rolling my own module to simply add the
$
and commas in the correct positions, since ultimately that's all we needed.Thanks for reaching out, I appreciate the feedback and advice!
Ah! Totally missed that! Hard to go back to Ruby after all this jazz, huh?