Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ExDatalog.Schema` — Ecto-inspired DSL macro module for defining Datalog programs
- `relation/2` macro declares typed relation schemas
- `fact/1` and `facts/2` macros declare ground facts
- `rule/2` macro declares rules with lowercase logic variables, `not_` for negation, named constraint predicates (`gt`, `eq`, `add`, etc.)
- `rule/2` macro declares rules with uppercase logic variables, `not_` for negation, named constraint predicates (`gt`, `eq`, `add`, etc.)
- `query/2` macro declares named post-materialization queries with `find`/`where`
- `wildcard/0` helper for explicit wildcards in rule bodies
- Generated `program/0`, `materialize/0,1`, `queries/0`, `query/2` functions
Expand All @@ -28,8 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Notes
- Query DSL operates on materialized knowledge only (no query planner yet)
- Aggregate syntax is parsed but not yet executablereturns `%UnsupportedFeature{feature: :aggregates}`
- All 718 existing tests continue to pass (now 751 total)
- Aggregate syntax is not yet supportedusing `agg(...)` raises `ExDatalog.DSL.CompileError` at compile time
- All 718 existing tests continue to pass (now 786 total)

## [0.3.0] - 2025-06-19

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ It continues to influence modern databases, compilers, static analysis tools, kn
- **Provenance / derivation explain** (`explain: true`)
- **Telemetry** integration (`:telemetry` events for query lifecycle)
- **Deterministic**: same program + same facts = same result regardless of backend
- 751 tests, 0 failures, credo clean
- 786 tests, 0 failures, credo clean

## Installation

Expand Down Expand Up @@ -101,7 +101,7 @@ AncestorRules.query(:descendants_of_alice, knowledge)
#=> [:bob, :carol, :dave]
```

Lowercase variables in rule bodies are logic variables. Constants use
Uppercase identifiers in rule bodies are logic variables. Constants use
atom syntax (`:alice`). Use `_` or `wildcard()` for wildcards. Negation
uses `not_`:

Expand Down
16 changes: 8 additions & 8 deletions docs/articles/01_why_datalog_on_the_beam.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ This isn't just a philosophical alignment. ExDatalog's `Knowledge` struct holds
Pattern matching is the other shared primitive. In Datalog, a rule body like:

```elixir
rule ancestor(x, z) do
parent(x, y)
ancestor(y, z)
rule ancestor(X, Z) do
parent(X, Y)
ancestor(Y, Z)
end
```

Expand Down Expand Up @@ -57,13 +57,13 @@ defmodule AncestorRules do
field :child, :atom
end

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

rule ancestor(x, z) do
parent(x, y)
ancestor(y, z)
rule ancestor(X, Z) do
parent(X, Y)
ancestor(Y, Z)
end
end
```
Expand Down
2 changes: 1 addition & 1 deletion docs/articles/05_negation_constraints_and_safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ This is the range-restriction property: every head variable must be bound by a p

## What's Coming in v0.5.0

- **Aggregates** — the syntax `agg(:count, X)` is already parsed but returns `%UnsupportedFeature{feature: :aggregates}`. The implementation will add count, sum, min, and max with proper safety checks.
- **Aggregates** — the syntax `agg(:count, X)` is not yet supported. Using it raises `ExDatalog.DSL.CompileError` at compile time. The implementation will add count, sum, min, and max with proper safety checks.
- **Magic sets / demand-driven evaluation** — goal-directed evaluation that computes only facts relevant to a specific query, instead of the full fixpoint.
- **General predicates as BEAM callbacks** — arbitrary Elixir functions as predicates, extending Datalog's reasoning with Elixir's computation while maintaining stratification and safety.

Expand Down
135 changes: 81 additions & 54 deletions lib/ex_datalog/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ defmodule ExDatalog.Schema do
An Ecto-inspired DSL for defining Datalog programs.

`use ExDatalog.Schema` in a module to declare relations, facts, rules,
and queries. The module then exposes `program/0`, `materialize/0`,
and queries. The module then exposes `program/0`, `materialize/0,1`,
and `query/2` functions.

## Example
Expand All @@ -46,18 +46,18 @@ defmodule ExDatalog.Schema do
fact parent(:alice, :bob)
fact parent(:bob, :carol)

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

rule ancestor(x, z) do
parent(x, y)
ancestor(y, z)
rule ancestor(X, Z) do
parent(X, Y)
ancestor(Y, Z)
end

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

Expand Down Expand Up @@ -91,11 +91,11 @@ defmodule ExDatalog.Schema do

## Rule DSL

Rules derive new facts. Lowercase identifiers are logic variables,
atoms starting with `:` are constants, and `_` is a wildcard:
Rules derive new facts. Uppercase identifiers are logic variables,
lowercase atoms and `:atoms` are constants, and `_` is a wildcard:

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

Negation uses `not_`:
Expand All @@ -107,9 +107,9 @@ defmodule ExDatalog.Schema do

Constraints use named predicates:

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

Supported constraints: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`,
Expand All @@ -121,23 +121,22 @@ defmodule ExDatalog.Schema do
Queries define named post-materialization lookups:

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

Queries operate on materialized knowledge and use `Knowledge.match/3`
internally.

## Aggregate Syntax (Preview)

Aggregates are parsed but not yet executable:
Aggregates are not yet supported. Using `agg(...)` in a rule head or
body raises `ExDatalog.DSL.CompileError` at compile time:

rule employee_count(dept, agg(:count, emp)) do
employee(emp, dept)
end

Attempting to materialize a program with aggregates returns
`{:error, %ExDatalog.UnsupportedFeature{feature: :aggregates}}`.
#=> ** (ExDatalog.DSL.CompileError) aggregates are not yet supported (planned for v0.6.0)

## Backward Compatibility

Expand Down Expand Up @@ -314,11 +313,11 @@ defmodule ExDatalog.Schema do
end
end)

validate_rules!(program, rules)
validate_rules!(rules)
program
end

defp validate_rules!(_program, rules) do
defp validate_rules!(rules) do
Enum.each(rules, fn {{head_rel, head_terms}, body_literals, constraints} ->
head_vars =
head_terms |> Enum.filter(&match?({:var, _}, &1)) |> Enum.map(fn {:var, n} -> n end)
Expand Down Expand Up @@ -461,7 +460,10 @@ defmodule ExDatalog.Schema do
[%ExDatalog.Schema.Field{name: name, type: type}]
end

defp extract_field(_), do: []
defp extract_field(other) do
raise ExDatalog.DSL.CompileError,
message: "unrecognized expression in relation block: #{inspect(other)}"
end

# --- Fact macros ---

Expand Down Expand Up @@ -524,35 +526,42 @@ defmodule ExDatalog.Schema do
[args]
end

defp extract_rows(_), do: []
defp extract_rows(other) do
raise ExDatalog.DSL.CompileError,
message: "unrecognized expression in facts block: #{inspect(other)}"
end

defp extract_row({:row, _, args}), do: [args]
defp extract_row(_), do: []

defp extract_row(other) do
raise ExDatalog.DSL.CompileError,
message: "unrecognized expression in facts block: #{inspect(other)}"
end

# --- Rule macro ---

@doc """
Declares a Datalog rule.

Lowercase identifiers in the head and body are logic variables.
Atoms starting with `:` are constants. `_` is a wildcard.
Uppercase identifiers in the head and body are logic variables.
Lowercase atoms and `:atoms` are constants. `_` is a wildcard.

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

Negation uses `not_`:

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

Constraints use named predicates:

rule high_earner(p) do
income(p, salary)
gt(salary, 100_000)
rule high_earner(P) do
income(P, S)
gt(S, 100_000)
end
"""
defmacro rule(head, do: body) do
Expand Down Expand Up @@ -583,8 +592,8 @@ defmodule ExDatalog.Schema do
end

defp parse_rule_head(_other) do
raise CompileError,
description: "rule head must be a relation call like `ancestor(x, y)`"
raise ExDatalog.DSL.CompileError,
message: "rule head must be a relation call like `ancestor(X, Y)`"
end

defp parse_term({:wildcard, _, []}) do
Expand Down Expand Up @@ -612,9 +621,14 @@ defmodule ExDatalog.Schema do
defp parse_term(integer) when is_integer(integer), do: {:const, integer}
defp parse_term(string) when is_binary(string), do: {:const, string}

defp parse_term({:agg, _, _args}) do
raise ExDatalog.DSL.CompileError,
message: "aggregates are not yet supported (planned for v0.6.0)"
end

defp parse_term(other) do
raise CompileError,
description: "unsupported term in DSL: #{inspect(other)}"
raise ExDatalog.DSL.CompileError,
message: "unsupported term in DSL: #{inspect(other)}"
end

defp create_term(name) when is_binary(name) do
Expand All @@ -637,9 +651,6 @@ defmodule ExDatalog.Schema do

{:constraint, c}, {body, constraints} ->
{body, constraints ++ [c]}

{:aggregate, agg}, {body, constraints} ->
{body, constraints ++ [agg]}
end)
end

Expand All @@ -657,8 +668,9 @@ defmodule ExDatalog.Schema do
parse_body_call({:not_, [], [rel_call]})
end

defp parse_body_call({:agg, _, args}) when is_list(args) do
{:aggregate, %ExDatalog.UnsupportedFeature{feature: :aggregates, planned_for: "v0.6.0"}}
defp parse_body_call({:agg, _, _args}) do
raise ExDatalog.DSL.CompileError,
message: "aggregates are not yet supported (planned for v0.6.0)"
end

constraint_ops = [
Expand Down Expand Up @@ -696,8 +708,8 @@ defmodule ExDatalog.Schema do
end

defp parse_body_call(other) do
raise CompileError,
description: "unsupported body expression in rule: #{inspect(other)}"
raise ExDatalog.DSL.CompileError,
message: "unsupported body expression in rule: #{inspect(other)}"
end

constraint_2_arity = [:eq, :neq, :gt, :gte, :lt, :lte, :starts_with, :contains]
Expand Down Expand Up @@ -734,8 +746,8 @@ defmodule ExDatalog.Schema do
end)

defp build_constraint(op, args) do
raise CompileError,
description: "unsupported constraint #{op}/#{length(args)}: #{inspect(args)}"
raise ExDatalog.DSL.CompileError,
message: "unsupported constraint #{op}/#{length(args)}: #{inspect(args)}"
end

# --- Query macro ---
Expand All @@ -761,6 +773,20 @@ defmodule ExDatalog.Schema do
def __register_query__(module, name, block) do
{find_vars, relation, pattern} = parse_query_block(block)

where_vars =
pattern
|> Enum.filter(&match?({:var, _}, &1))
|> Enum.map(fn {:var, n} -> n end)
|> MapSet.new()

missing = MapSet.difference(MapSet.new(find_vars), where_vars)

if MapSet.size(missing) > 0 do
raise ExDatalog.DSL.CompileError,
message:
"query #{name}: find variable(s) #{Enum.join(MapSet.to_list(missing), ", ")} not present in where pattern"
end

Module.put_attribute(module, :ex_datalog_queries, %ExDatalog.Schema.QueryMeta{
name: name,
relation: relation,
Expand All @@ -787,8 +813,9 @@ defmodule ExDatalog.Schema do
end)
end

defp parse_query_block({:find, _, [find_var]}) do
parse_query_block({:__block__, [], [{:find, [], [find_var]}, {:where, [], [{:_, [], nil}]}]})
defp parse_query_block({:find, _, [_find_var]}) do
raise ExDatalog.DSL.CompileError,
message: "query requires a `where` clause (e.g., `where relation(X, Y)`)"
end

defp parse_query_term({:__aliases__, _, [alias_name]} = _var) when is_atom(alias_name) do
Expand Down Expand Up @@ -840,9 +867,9 @@ defmodule ExDatalog.Schema do
iex> ExDatalog.Schema.wildcard()
:wildcard

rule bachelor(p) do
male(p)
not_ married(p, wildcard())
rule bachelor(P) do
male(P)
not_ married(P, _)
end
"""
@spec wildcard() :: :wildcard
Expand Down
Loading
Loading