Powered by AppSignal & Oban Pro

ExDatalog DSL Tutorial

livebooks/ex_datalog_dsl.livemd

ExDatalog DSL Tutorial

Mix.install([
    {:ex_datalog, path: Path.expand("..", __DIR__), env: :prod},
])

Section

Welcome to the ExDatalog DSL tutorial! This livebook walks through the use ExDatalog.Schema DSL (v0.4.0) for declaring Datalog programs in Elixir.

Mix.install([{:ex_datalog, "~> 0.4.0"}])

1. Relations — Declaring Typed Schemas

Relations are the core data declarations in a Datalog program. Each relation defines a named schema with typed fields, similar to database tables.

Supported field types: :atom, :integer, :string, :any.

defmodule Family do
  use ExDatalog.Schema

  relation :parent do
    field :parent, :atom
    field :child, :atom
  end

  relation :ancestor do
    field :ancestor, :atom
    field :descendant, :atom
  end

  relation :income do
    field :person, :atom
    field :amount, :integer
  end

  relation :label do
    field :node, :atom
    field :text, :string
  end
end

Inspect the program to see the declared relations:

program = Family.program()
program.relations

You can also mix field types within a single relation. The :any type allows unrestricted values:

defmodule VarTypes do
  use ExDatalog.Schema

  relation :payload do
    field :key, :atom
    field :value, :any
  end
end

VarTypes.program().relations

2. Facts — Single Facts and Bulk Facts

Facts assert ground (variable-free) tuples into relations. You can declare them one at a time with fact or in bulk with facts.

Single facts

Use fact relation_name(const1, const2, ...) to insert individual tuples. Atom constants start with :.

defmodule SingleFacts do
  use ExDatalog.Schema

  relation :parent do
    field :parent, :atom
    field :child, :atom
  end

  fact parent(:alice, :bob)
  fact parent(:bob, :carol)
end

SingleFacts.program().facts
|> Enum.map(fn {rel, vals} -> {rel, vals} end)

Bulk facts

When you have many rows, use the facts macro with row syntax:

defmodule BulkFacts do
  use ExDatalog.Schema

  relation :edge do
    field :from, :atom
    field :to, :atom
  end

  facts :edge do
    row :a, :b
    row :b, :c
    row :c, :d
    row :d, :e
  end
end

BulkFacts.program().facts
|> Enum.map(fn {rel, vals} -> {rel, vals} end)

3. Recursive Rules — Ancestor Transitive Closure

Rules derive new facts from existing ones. Lowercase identifiers that start with an uppercase letter are logic variables (X, Y, Z); atoms starting with : are constants; _ is a wildcard.

The classic Datalog example is computing transitive closure — here, the ancestor relation from parent:

defmodule Ancestor do
  use ExDatalog.Schema

  relation :parent do
    field :parent, :atom
    field :child, :atom
  end

  relation :ancestor do
    field :ancestor, :atom
    field :descendant, :atom
  end

  fact parent(:alice, :bob)
  fact parent(:bob, :carol)
  fact parent(:carol, :dave)

  # Base case: every parent is an ancestor
  rule ancestor(X, Y) do
    parent(X, Y)
  end

  # Recursive case: if X is parent of Y, and Y is ancestor of Z,
  # then X is ancestor of Z
  rule ancestor(X, Z) do
    parent(X, Y)
    ancestor(Y, Z)
  end
end

{:ok, knowledge} = Ancestor.materialize()

ancestor = ExDatalog.Knowledge.get(knowledge, "ancestor")
MapSet.to_list(ancestor) |> Enum.sort()

The result includes all ancestor pairs — both direct (alice → bob) and transitive (alice → dave).

4. Materialization — Running Programs to Get Knowledge

materialize/0 (or materialize/1 with options) validates, compiles, and evaluates the program in one step. It returns {:ok, knowledge} on success or {:error, reason} on failure.

defmodule Route do
  use ExDatalog.Schema

  relation :link do
    field :from, :atom
    field :to, :atom
  end

  relation :path do
    field :from, :atom
    field :to, :atom
  end

  facts :link do
    row :a, :b
    row :b, :c
    row :c, :d
  end

  rule path(X, Y) do
    link(X, Y)
  end

  rule path(X, Z) do
    link(X, Y)
    path(Y, Z)
  end
end

{:ok, knowledge} = Route.materialize()

Check termination status — always verify that the fixpoint was reached:

knowledge.stats.termination

Inspect all derived path tuples:

ExDatalog.Knowledge.get(knowledge, "path")
|> MapSet.to_list()
|> Enum.sort()

You can also list all relation names and their sizes:

Enum.map(ExDatalog.Knowledge.relations(knowledge), fn rel ->
  {rel, ExDatalog.Knowledge.size(knowledge, rel)}
end)

materialize/1 also accepts options like :max_iterations and :timeout_ms:

{:ok, knowledge} = Route.materialize(max_iterations: 1000)
knowledge.stats.termination

5. Queries — Named Post-Materialization Queries with find/where

The query macro defines named lookups against materialized knowledge. find specifies which variables to extract; where specifies the relation and pattern to match.

defmodule FamilyQueries do
  use ExDatalog.Schema

  relation :parent do
    field :parent, :atom
    field :child, :atom
  end

  relation :ancestor do
    field :ancestor, :atom
    field :descendant, :atom
  end

  fact parent(:alice, :bob)
  fact parent(:bob, :carol)
  fact parent(:carol, :dave)

  rule ancestor(X, Y) do
    parent(X, Y)
  end

  rule ancestor(X, Z) do
    parent(X, Y)
    ancestor(Y, Z)
  end

  query :all_ancestors do
    find X, Y
    where ancestor(X, Y)
  end

  query :descendants_of_alice do
    find Y
    where ancestor(:alice, Y)
  end
end

{:ok, knowledge} = FamilyQueries.materialize()

Single-column find returns a list of values; multi-column find returns a list of tuples:

FamilyQueries.query(:descendants_of_alice, knowledge)
FamilyQueries.query(:all_ancestors, knowledge) |> Enum.sort()

Inspect query metadata with queries/0:

FamilyQueries.queries()

6. Negation — Using not_ in Rule Bodies

Datalog supports stratified negation via the not_ prefix. A negated body literal excludes bindings that match.

For example, find bachelors — people who are male but not married:

defmodule Bachelors do
  use ExDatalog.Schema

  relation :male do
    field :person, :atom
  end

  relation :married do
    field :person, :atom
    field :spouse, :atom
  end

  relation :bachelor do
    field :person, :atom
  end

  fact male(:bob)
  fact male(:tom)
  fact male(:dave)

  fact married(:tom, :sally)
  fact married(:dave, :amy)

  rule bachelor(P) do
    male(P)
    not_ married(P, _)
  end
end

{:ok, knowledge} = Bachelors.materialize()

bachelors = ExDatalog.Knowledge.get(knowledge, "bachelor")
MapSet.to_list(bachelors)

Only bob qualifies — tom and dave are married. The _ in not_ married(P, _) is a wildcard that matches any spouse.

If Elixir’s treatment of _ causes issues, you can use wildcard() explicitly:

# These are equivalent inside a rule body:
#   not_ married(P, _)
#   not_ married(P, wildcard())

7. Constraints — Comparison, Arithmetic, Type Predicates

Constraints filter or compute values within rule bodies. They are written as named predicates.

Comparison constraints

Compare bound values: eq, neq, gt, gte, lt, lte.

defmodule HighEarners do
  use ExDatalog.Schema

  relation :income do
    field :person, :atom
    field :salary, :integer
  end

  relation :high_earner do
    field :person, :atom
  end

  fact income(:alice, 150_000)
  fact income(:bob, 50_000)
  fact income(:carol, 200_000)
  fact income(:dave, 100_000)

  rule high_earner(P) do
    income(P, S)
    gt(S, 100_000)
  end
end

{:ok, knowledge} = HighEarners.materialize()
ExDatalog.Knowledge.get(knowledge, "high_earner") |> MapSet.to_list()

Arithmetic constraints

Compute new values: add, sub, mul, div. The third argument is a result variable that receives the computed value.

defmodule Compensation do
  use ExDatalog.Schema

  relation :salary do
    field :person, :atom
    field :base, :integer
  end

  relation :total_comp do
    field :person, :atom
    field :total, :integer
  end

  fact salary(:alice, 100)
  fact salary(:bob, 80)

  rule total_comp(P, T) do
    salary(P, B)
    add(B, 20, T)
  end
end

{:ok, knowledge} = Compensation.materialize()
ExDatalog.Knowledge.get(knowledge, "total_comp") |> MapSet.to_list() |> Enum.sort()

Type predicates

Check value types: is_integer, is_binary, is_atom.

defmodule TypeFilter do
  use ExDatalog.Schema

  relation :value do
    field :v, :any
  end

  relation :int_val do
    field :v, :any
  end

  fact value(42)
  fact value(:not_int)

  rule int_val(V) do
    value(V)
    is_integer(V)
  end
end

{:ok, knowledge} = TypeFilter.materialize()
ExDatalog.Knowledge.get(knowledge, "int_val") |> MapSet.to_list()

Equality and inequality

defmodule EqualityDemo do
  use ExDatalog.Schema

  relation :pair do
    field :a, :atom
    field :b, :atom
  end

  relation :same do
    field :x, :atom
  end

  relation :different do
    field :a, :atom
    field :b, :atom
  end

  fact pair(:x, :x)
  fact pair(:a, :b)
  fact pair(:b, :a)

  rule same(A) do
    pair(A, B)
    eq(A, B)
  end

  rule different(A, B) do
    pair(A, B)
    neq(A, B)
  end
end

{:ok, knowledge} = EqualityDemo.materialize()
IO.puts("Same:  #{inspect(MapSet.to_list(ExDatalog.Knowledge.get(knowledge, "same")))}")
IO.puts("Different: #{inspect(MapSet.to_list(ExDatalog.Knowledge.get(knowledge, "different")))}")

String and membership constraints

  • starts_with(var, "prefix") — filters strings that start with a prefix
  • contains(var, "substring") — filters strings that contain a substring
  • member(var, [:a, :b, :c]) — filters values in a constant list

Full constraint reference

Category Operators
Comparison eq, neq, gt, gte, lt, lte
Arithmetic add, sub, mul, div
Type is_integer, is_binary, is_atom
String starts_with, contains
Membership member

8. Aggregate Syntax Preview — Parsed but Not Executable

Aggregates have a parsed syntax but are not yet executable. Writing a rule with agg(:function, variable) triggers parsing, but materialization returns an error:

defmodule AggPreview do
  use ExDatalog.Schema

  relation :employee do
    field :emp, :atom
    field :dept, :atom
  end

  relation :employee_count do
    field :dept, :atom
    field :total, :any
  end

  facts :employee do
    row :alice, :engineering
    row :bob, :engineering
    row :carol, :infra
  end

  # This rule parses but cannot be materialized yet.
  # The agg/2 syntax is reserved for a future release (v0.6.0).
  #
  # rule employee_count(D, agg(:count, E)) do
  #   employee(E, D)
  # end
end

Attempting to materialize a program containing aggregates will return:

# {:error, %ExDatalog.UnsupportedFeature{feature: :aggregates, planned_for: "v0.6.0"}}

The UnsupportedFeature struct can be inspected directly:

%ExDatalog.UnsupportedFeature{feature: :aggregates, planned_for: "v0.6.0"}
|> IO.inspect()

When aggregates land in a future release, the syntax will look like:

# rule employee_count(D, agg(:count, E)) do
#   employee(E, D)
# end

Until then, you can compute aggregations in Elixir by querying materialized knowledge and post-processing the results.