Reputation: 3307
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
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