Building an Elixir Datalog DSL: The Design of ExDatalog v0.4.0's Schema Macro
Copy Markdown View SourceExDatalog v0.3.0 offered a builder API: you assembled programs by piping Program.add_relation/3, Program.add_fact/3, and Program.add_rule/2 calls. It worked, but it looked like configuration code, not like expressing logical rules. Version 0.4.0 introduces the Schema DSL — a set of compile-time macros that let you declare relations, facts, rules, and queries inside an Elixir module.
This article walks through the design decisions and explains how the DSL compiles down to the builder API.
Why Ecto-Inspired Macros?
ExDatalog's Schema DSL follows the same pattern as Ecto's Ecto.Schema. Both need to collect declarations at compile time and generate runtime functions from those declarations. The @before_compile pattern is essential: you can't generate program/0 until all relation, fact, and rule macros have finished registering their data.
defmacro __using__(_opts) do
quote do
import ExDatalog.Schema, only: [relation: 2, fact: 1, facts: 2, rule: 2, query: 2, wildcard: 0]
Module.register_attribute(__MODULE__, :ex_datalog_relations, accumulate: true)
Module.register_attribute(__MODULE__, :ex_datalog_facts, accumulate: true)
Module.register_attribute(__MODULE__, :ex_datalog_rules, accumulate: true)
Module.register_attribute(__MODULE__, :ex_datalog_queries, accumulate: true)
@before_compile ExDatalog.Schema
end
endFour module attributes accumulate declarations. Each macro call appends to the corresponding attribute, and __before_compile__ reads them all to generate program/0, materialize/0, queries/0, and query/2:
defmacro __before_compile__(env) do
relations = Module.get_attribute(env.module, :ex_datalog_relations) |> Enum.reverse()
facts = Module.get_attribute(env.module, :ex_datalog_facts) |> Enum.reverse()
rules = Module.get_attribute(env.module, :ex_datalog_rules) |> Enum.reverse()
queries = Module.get_attribute(env.module, :ex_datalog_queries) |> Enum.reverse()
quote do
def program do
ExDatalog.Schema.__build_program__(unquote(Macro.escape(relations)),
unquote(Macro.escape(facts)),
unquote(Macro.escape(rules)))
end
def materialize(opts \\ []) do
ExDatalog.materialize(program(), opts)
end
def queries do
unquote(Macro.escape(Map.new(queries, fn q -> {q.name, q} end)))
end
def query(name, knowledge) do
ExDatalog.Schema.__execute_query__(name, knowledge, unquote(Macro.escape(queries)))
end
end
endEnum.reverse/1 restores declaration order because accumulate: true prepends. Macro.escape/1 converts the Elixir data structures into AST literals that the quote block can inject.
The Variable Convention
The DSL follows Prolog convention for distinguishing variables from constants: uppercase identifiers are logic variables, lowercase atoms and colon-prefixed atoms are constants, and _ is a wildcard.
This convention is driven by how Elixir's AST represents identifiers. Uppercase names like X and Y are parsed as __aliases__ nodes, producing a distinct AST shape from lowercase variables. The parse_term/1 function dispatches on these shapes:
# Uppercase identifiers (module aliases) → logic variables
defp parse_term({:__aliases__, _, [alias_name]}) when is_atom(alias_name) do
{:var, Atom.to_string(alias_name)}
end
# Bare identifiers with nil context
defp parse_term({var_name, _context, nil}) when is_atom(var_name) do
var_str = Atom.to_string(var_name)
cond do
var_str == "_" -> :wildcard
var_str =~ ~r/^[A-Z]/ -> {:var, var_str}
true -> {:const, var_name}
end
end
# Atoms with colon prefix → constants
defp parse_term(atom) when is_atom(atom) do
Atom.to_string(atom) |> create_term()
end| DSL Syntax | Internal Form | Meaning |
|---|---|---|
X, Y, Z | {:var, "X"}, {:var, "Y"} | Logic variable |
:alice, :bob | {:const, :alice}, {:const, :bob} | Constant |
_ | :wildcard | Anonymous variable |
The test suite confirms: rule reachable(:start, Y) produces head.terms == [Term.from(:start), Term.var("Y")], where :start is a constant and Y is a variable.
Macro Hygiene and Macro.escape
The rule/2 macro uses Macro.escape on both the head and body, which is critical. Without escaping, Elixir would try to evaluate the AST as runtime code. With escaping, the AST is preserved as data and passed to __register_rule__/3 at compile time:
defmacro rule(head, do: body) do
quote do
ExDatalog.Schema.__register_rule__(__MODULE__,
unquote(Macro.escape(head)),
unquote(Macro.escape(body)))
end
endThis means the DSL never evaluates parent(X, Y) as a function call. It parses the AST representation {:parent, [line: N], [{:__aliases__, ..., [:X]}, {:__aliases__, ..., [:Y]}]} and extracts the relation name and terms as data.
What the DSL Doesn't Do (Yet)
The v0.4.0 DSL deliberately omits some features:
- Aggregates — the syntax
rule employee_count(dept, agg(:count, emp))is parsed but returns%UnsupportedFeature{feature: :aggregates}. Materializing such a program fails with a clear error. - Query planning — the
querymacro callsKnowledge.match/3on materialized knowledge. There's no query optimizer or cost model. - Schema validation — the DSL doesn't check at compile time that a
factreferences a declared relation, or that arulehead's arity matches itsrelationdeclaration. These checks happen at runtime through the builder API andValidator.
The DSL's job in v0.4.0 is to make the common case ergonomic and readable. The builder API remains the fully-capable interface for dynamic program construction or features the DSL doesn't yet cover.
Good Elixir macros don't hide complexity — they eliminate boilerplate. The Schema DSL compiles to the same builder API, so every macro feature is also available programmatically. The __build_program__/3 function iterates over the collected relations, facts, and rules, calling Program.add_relation/3, Program.add_fact/3, and Program.add_rule/2 — the exact same pipeline a hand-written builder would use. At runtime, there's no macro expansion, no AST walking — just the builder pipeline running on pre-computed data structures.
This design also means you can mix both approaches: define the static structure of your program with the DSL, then extend it with the builder API at runtime. The test suite verifies this with a backward-compatibility test that defines a schema, calls program/0, then adds additional facts and rules via Program.add_fact/3 and Program.add_rule/2 before materializing.