squarism
squarism

Reputation: 3307

Elixir mixins used for validation callbacks

I've read other SO answers around this subject but I specifically wanted to focus on Elixir's own documentation that talks about mixins and DSLs.

In their example, they say that these are three options:

# 1. data structures
import Validator
validate user, name: [length: 1..100],
               email: [matches: ~r/@/]

# 2. functions
import Validator
user
|> validate_length(:name, 1..100)
|> validate_matches(:email, ~r/@/)

# 3. macros + modules
defmodule MyValidator do
  use Validator
  validate_length :name, 1..100
  validate_matches :email, ~r/@/
end

MyValidator.validate(user)

Of course, the examples aren't complete. So I thought I'd try to complete style #3 to better understand some of the libraries I'm using that uses some of these styles (not necessarily style #3).

There's kind of a gotcha with trying to do this in a test.exs file because the struct was not yet defined or the struct is being accessed in the same context that defines it but getting around that with a Main module leads me to something like this:

defmodule Validator do

  defmacro __using__(_params) do
    quote do
      def validate_length(field, length_rules) do
        String.length(field) >= length_rules.first and String.length(field) <= length_rules.last
      end
    end
  end

end

defmodule MyValidator do
  use Validator
  validate_length :name, 1..10
end

defmodule User do
  @enforce_keys [:name]
  defstruct [:name]
end

defmodule Main do
  def run do
    user = %User{name: "Joe"}
    MyValidator.validate(user)
  end
end

Main.run
# undefined function validate_length/2

I wouldn't expect this to work because I don't understand the connection between MyValidator and the "callbacks" for what validate means. Am I supposed to look up (metaprogram) what validations have been used? Am I supposed to implement _params to see what callbacks are to be used in the future?

Even so, the error is undefined function validate_length/2 because there's no "wiring" between the two. Of course if I change the main to be something like:

defmodule MyValidator do
  use Validator
end

def run do
  user = %User{name: "Joe"}
  MyValidator.validate_length(user.name, 1..10)

Then of course that works but that's not doing a callback, that's just a mixin.

So how can you finish up the Elixir example #3 to act like a Mixin that could have many validators, many callbacks?

Upvotes: 0

Views: 128

Answers (1)

Aleksei Matiushkin
Aleksei Matiushkin

Reputation: 121000

You should start with strict disunion of two contexts: compilation time context vs runtime context.

To do this, one might start with the following example:

defmodule Foo do
  IO.puts "Compilation"

  def bar(), do: IO.puts "Runtime"
end

When Elixir compiles this, it prints the former. When you call Foo.bar/0, you’ll get the latter printed.


Now to macros. Macros are compilation time beasts. During the compilation stage, Elixir takes AST that the macro returns, and explicitly injects it into the place where the macro was called from. Consider the following example.

defmodule Foo do
  defmacro bar() do
    IO.puts "Compilation"
    quote do: IO.puts "Runtime"
  end

  def baz(), do: bar()
end

Try to compile it and then call Foo.baz/0. The thing is, Elixir macro language is Elixir and compiler is fine with executing code while traversing macros to AST. That’s why you get "Compilation" string printed only once. After compilation passed, the former IO.puts/2 call does not exist anymore.


Now your example. In the first place don’t try to put the code that is expected to be compiled in *.exs. s stands for script and these files are not compiled during normal compilation stage by default. There are many reasons for that, but they are definitely out of scope here. So, put the code into separate files with *.ex extension.

Important: you want the validate/1 function to be available in compilation time.

So, use Validator should a) inject the code for this function and b) import it into current context during compilation time. For it to be available during compilation stage, it should reside in a different module because this module must be already compiled by a time of injection. Elixir is not a scripting language and it cannot run not compiled code.

The summing up.

defmodule Validator do
  defmacro __using__(_) do
    quote do
      # you need this module IMPORTED
      import Validator
    end
  end
  # you need this function COMPILED
  def validate(foo) do
    if foo > 42,
      do: raise(ArgumentError, "FOO"),
      else: IO.puts("OK")
  end
end

defmodule Test do
  use Validator

  validate 0    # prints out "OK"
  validate 100  # raises _during compilation_
end

For more sophisticated checks validate function might inject runtime AST.

Upvotes: 1

Related Questions