The qb package provides an idiomatic Go API for building Datalog queries without string manipulation. The key insight: Go variable identity IS the join condition - using the same *Var in multiple patterns creates a join.
import "github.com/wbrown/janus-datalog/datalog/qb"
// Define attributes as package-level constants - this is the recommended pattern
var (
PersonName = qb.Kw(":person/name")
PersonAge = qb.Kw(":person/age")
PersonCity = qb.Kw(":person/city")
)
func FindAdults(d *db.DB) ([][]interface{}, error) {
// Variables are created per-query - same pointer = same logical variable
e := qb.NewVar("e")
name := qb.NewVar("name")
age := qb.NewVar("age")
q := qb.Query().
Find(name, age).
Where(
qb.Pat(e, PersonName, name),
qb.Pat(e, PersonAge, age), // same `e` = join!
qb.Gt(age, 21),
).
MustBuild()
return d.Query(q)
}Define attributes as package-level constants. This prevents typos, enables IDE completion, and makes refactoring safe.
// schema.go - define your schema's attributes once
package myapp
import "github.com/wbrown/janus-datalog/datalog/qb"
// Person attributes
var (
PersonName = qb.Kw(":person/name")
PersonAge = qb.Kw(":person/age")
PersonEmail = qb.Kw(":person/email")
PersonCity = qb.Kw(":person/city")
PersonActive = qb.Kw(":person/active")
)
// Order attributes
var (
OrderCustomer = qb.Kw(":order/customer")
OrderTotal = qb.Kw(":order/total")
OrderDate = qb.Kw(":order/date")
OrderStatus = qb.Kw(":order/status")
)Then use them throughout your code:
// queries.go
q := qb.Query().
Find(name, total).
Where(
qb.Pat(p, PersonName, name),
qb.Pat(o, OrderCustomer, p),
qb.Pat(o, OrderTotal, total),
).
MustBuild()Why this matters:
- Typo in
":person/naem"silently returns no results - Constant
PersonNamecatches typos at compile time - Refactoring attribute names is safe with find-and-replace
- IDE autocompletion shows available attributes
Variables represent unknowns in your query. The same *Var pointer used in multiple places creates a join condition.
e := qb.NewVar("e")
name := qb.NewVar("name")
age := qb.NewVar("age")
// Same variable in multiple patterns = join
qb.Pat(e, PersonName, name)
qb.Pat(e, PersonAge, age) // joins on ePat creates data patterns of any arity. Patterns can match against:
- The database (EAVT storage)
- Input relations of any arity
// Database patterns
qb.Pat(e, a, v) // [e a v] - standard pattern
qb.Pat(e, a, v, tx) // [e a v tx] - with transaction
qb.Pat(e, a, v, tx, op) // [e a v tx op] - history pattern
// Input relation patterns (any arity)
qb.Pat(name, age) // 2-tuple
qb.Pat(a, b, c, d, e, f) // 6-tuple
// Wildcards
qb.Pat(e, qb.Blank(), name) // ignore attributePatterns accept:
*Var- query variablesqb.Kw(":attr/name")- keyword attributesqb.V(value)- constant valuesqb.Blank()- wildcard (ignore position)- Raw Go values - converted to constants
// Using Kw for attributes
PersonName := qb.Kw(":person/name")
qb.Pat(e, PersonName, name)
// Using V for constants
qb.Pat(e, PersonName, qb.V("Alice"))
// Raw values work too
qb.Pat(e, PersonName, "Alice")Comparison predicates filter results:
qb.Lt(age, 30) // age < 30
qb.Lte(age, 30) // age <= 30
qb.Gt(age, 18) // age > 18
qb.Gte(age, 18) // age >= 18
qb.Eq(city, "NYC") // city = "NYC"
qb.Ne(status, "X") // status != "X"// Exclusive range: 18 < age < 65
qb.Range(18, age, 65)
// Inclusive range: 1 <= rating <= 5
qb.RangeInclusive(1, rating, 5)
// General chained comparison with any operator
qb.Chained(query.OpLT, a, b, c, d) // a < b < c < dComparisons can also bind their boolean result to a variable using .As():
hasItems := qb.NewVar("hasItems")
q := qb.Query().
Find(name, count, hasItems).
Where(
qb.Pat(e, ItemName, name),
qb.Pat(e, ItemCount, count),
qb.Gt(count, 0).As(hasItems), // binds true/false to hasItems
).
MustBuild()
// Results include the boolean:
// [["Widget", 5, true], ["Gadget", 0, false], ...]This works with all comparison types:
qb.Gt(x, 0).As(isPositive) // [(> ?x 0) ?is-positive]
qb.Lt(x, 100).As(isSmall) // [(< ?x 100) ?is-small]
qb.Eq(status, "active").As(isActive) // [(= ?status "active") ?is-active]
qb.Range(0, x, 100).As(inRange) // [(< 0 ?x 100) ?in-range]qb.Query().
Find(dept, qb.Sum(salary)).
Where(
qb.Pat(e, PersonDept, dept),
qb.Pat(e, PersonSalary, salary),
).
MustBuild()Available aggregations:
qb.Sum(v)- sum of valuesqb.Count(v)- count of valuesqb.Avg(v)- average of valuesqb.Min(v)- minimum valueqb.Max(v)- maximum value
Expressions compute values and bind them to variables:
total := qb.NewVar("total")
qb.Add(price, tax).As(total)
qb.Sub(gross, deductions, fees).As(net)
qb.Mul(quantity, unitPrice, multiplier).As(lineTotal)
qb.Div(total, count, scale).As(average)
qb.Sub(delta).As(negated)
qb.Div(value).As(reciprocal)Arithmetic requires at least one operand and accepts additional operands.
Longer forms reduce left-to-right. Direct AST construction now uses
query.ArithmeticFunction{Op: ..., Args: []query.Term{...}}; the old
Left/Right fields were removed.
fullName := qb.NewVar("fullName")
qb.Str(firstName, " ", lastName).As(fullName)constant := qb.NewVar("constant")
qb.Ground(42).As(constant)y := qb.NewVar("y")
qb.Year(timestamp).As(y)
qb.Month(timestamp).As(m)
qb.Day(timestamp).As(d)
qb.Hour(timestamp).As(h)
qb.Minute(timestamp).As(min)
qb.Second(timestamp).As(sec)Database functions access entity attributes with special semantics for missing values.
Returns an attribute value, or a default if the attribute is missing:
nickname := qb.NewVar("nickname")
q := qb.Query().
Find(name, nickname).
Where(
qb.Pat(e, PersonName, name),
qb.GetElse(e, PersonNickname, "Anonymous").As(nickname),
).
MustBuild()
// Results:
// [["Alice", "Ali"], ["Bob", "Anonymous"], ...]
// Bob has no nickname, so gets the defaultEquivalent EDN: [(get-else $ ?e :person/nickname "Anonymous") ?nickname]
As a predicate (filter tuples where attribute is missing):
q := qb.Query().
Find(name).
Where(
qb.Pat(e, PersonName, name),
qb.Missing(e, PersonEmail), // only people without email
).
MustBuild()Equivalent EDN: [(missing? $ ?e :person/email)]
As an expression (bind boolean result):
needsEmail := qb.NewVar("needsEmail")
q := qb.Query().
Find(name, needsEmail).
Where(
qb.Pat(e, PersonName, name),
qb.Missing(e, PersonEmail).As(needsEmail), // true if missing
).
MustBuild()
// Results:
// [["Alice", false], ["Bob", true], ...]
// Alice has email, Bob doesn'tEquivalent EDN: [(missing? $ ?e :person/email) ?needs-email]
Returns the first available attribute from a list (useful for display names, fallbacks):
displayName := qb.NewVar("displayName")
q := qb.Query().
Find(name, displayName).
Where(
qb.Pat(e, PersonName, name),
qb.GetSome(e, PersonNickname, PersonFullName, PersonEmail).As(displayName),
).
MustBuild()
// Returns first available: nickname > fullname > email
// [["Alice", "Ali"], ["Bob", "Robert Jones"], ["Charlie", "charlie@example.com"]]Equivalent EDN: [(get-some $ ?e :person/nickname :person/fullname :person/email) ?display-name]
Input parameters allow parameterized queries:
// Database source (always first)
qb.DB
// Scalar input - single value
qb.Scalar(nameVar)
// Collection input - iterate over values
qb.Collection(nameVar)
// Tuple input - single tuple of values
qb.Tuple(nameVar, ageVar)
// Relation input - multiple tuples
qb.Relation(nameVar, ageVar)
// Named source - additional database or PatternMatcher
qb.Source("$users")
// Source-qualified pattern
qb.PatFrom(source, e, attr, val)inputName := qb.NewVar("inputName")
age := qb.NewVar("age")
q := qb.Query().
Find(inputName, age).
In(qb.DB, qb.Scalar(inputName)).
Where(
qb.Pat(e, PersonName, inputName),
qb.Pat(e, PersonAge, age),
).
MustBuild()
// Execute with input value
results, err := d.Query(q, "Alice")inputName := qb.NewVar("inputName")
age := qb.NewVar("age")
q := qb.Query().
Find(inputName, age).
In(qb.DB, qb.Collection(inputName)).
Where(
qb.Pat(e, PersonName, inputName),
qb.Pat(e, PersonAge, age),
).
MustBuild()
// Execute with multiple values - returns results for each
results, err := d.Query(q, []string{"Alice", "Bob", "Charlie"})inputName := qb.NewVar("inputName")
inputCity := qb.NewVar("inputCity")
age := qb.NewVar("age")
q := qb.Query().
Find(inputName, inputCity, age).
In(qb.DB, qb.Relation(inputName, inputCity)).
Where(
qb.Pat(e, PersonName, inputName),
qb.Pat(e, PersonCity, inputCity),
qb.Pat(e, PersonAge, age),
).
MustBuild()
// Execute with relation - finds matching (name, city) pairs
results, err := d.Query(q, [][]interface{}{
{"Alice", "NYC"},
{"Bob", "LA"},
})Named sources let you query across multiple data sources. Use qb.Source() to declare a source and qb.PatFrom() to create source-qualified patterns:
users := qb.Source("$users")
perms := qb.Source("$perms")
e := qb.NewVar("e")
name := qb.NewVar("name")
uid := qb.NewVar("uid")
p := qb.NewVar("p")
role := qb.NewVar("role")
q := qb.Query().
Find(name, role).
In(users, perms).
Where(
qb.PatFrom(users, e, qb.Kw(":user/name"), name),
qb.PatFrom(users, e, qb.Kw(":user/id"), uid),
qb.PatFrom(perms, p, qb.Kw(":perm/user-id"), uid),
qb.PatFrom(perms, p, qb.Kw(":perm/role"), role),
).
MustBuild()
// Execute with named sources
results, err := d.Query(q,
storage.WithSources(map[query.Symbol]executor.PatternMatcher{
query.Symbol("$users"): usersDB,
query.Symbol("$perms"): permsDB,
}),
)To mix the default database with named sources, include qb.DB:
cache := qb.Source("$cache")
q := qb.Query().
Find(name, score).
In(qb.DB, cache).
Where(
qb.Pat(e, qb.Kw(":user/name"), name), // default database
qb.PatFrom(cache, e, qb.Kw(":score"), score), // named source
).
MustBuild()See MULTI_SOURCE.md for the complete multi-source reference.
Exclude results matching patterns:
// Exclude inactive people
qb.Not(
qb.Pat(e, PersonActive, false),
)
// NOT with join variable
qb.NotJoin([]*qb.Var{e},
qb.Pat(e, PersonStatus, qb.V("banned")),
)Match any of several alternatives:
// Match NYC or LA
qb.Or().
Branch(qb.Pat(e, PersonCity, qb.V("NYC"))).
Branch(qb.Pat(e, PersonCity, qb.V("LA")))
// OR with join variables
qb.OrJoin(e, city).
Branch(qb.Pat(e, PersonCity, city), qb.Eq(city, "NYC")).
Branch(qb.Pat(e, PersonCity, city), qb.Eq(city, "LA"))qb.Query().
Find(name, age).
Where(...).
OrderBy(qb.Desc(age), qb.Asc(name)).
MustBuild()// Inner query finds max salary per department
innerQ := qb.Query().
Find(qb.Max(innerSalary)).
In(qb.DB, qb.Scalar(dept)).
Where(
qb.Pat(emp, EmpDept, dept),
qb.Pat(emp, EmpSalary, innerSalary),
)
// Outer query uses subquery
maxSalary := qb.NewVar("maxSalary")
q := qb.Query().
Find(name, maxSalary).
Where(
qb.Pat(p, PersonName, name),
qb.Pat(p, PersonDept, dept),
qb.Subquery(innerQ, dept).BindTuple(maxSalary),
).
MustBuild()Binding forms:
BindTuple(vars...)- single tuple result[[?a ?b]]BindRelation(vars...)- multiple tuples[[?a ?b] ...]BindCollection(v)- single symbol[?a ...]
Datalog subqueries have lexical scoping - variables inside a subquery are independent of variables in other subqueries, even if they have the same name. This means you can reuse natural variable names like ?t and ?s across subqueries:
// Good: Reuse Datalog variable names, reassign Go variables between subqueries
t, s := qb.NewVar("t"), qb.NewVar("s")
tok, dur := qb.NewVar("tok"), qb.NewVar("dur")
taskStatsQuery := qb.Query().
Find(qb.Count(t), qb.Sum(tok), qb.Sum(dur)).
In(qb.DB, qb.Scalar(s)).
Where(
qb.Pat(t, TaskScenario, s),
qb.Pat(t, TaskTokens, tok),
qb.Pat(t, TaskDuration, dur),
)
// Reassign Go variables for second subquery - Datalog names stay the same
t, s = qb.NewVar("t"), qb.NewVar("s")
openingCountQuery := qb.Query().
Find(qb.Count(t)).
In(qb.DB, qb.Scalar(s)).
Where(
qb.Pat(t, TaskScenario, s),
qb.Pat(t, TaskKey, KeyOpening),
)Both subqueries generate ?t and ?s in their EDN. Datalog's lexical scoping keeps them separate.
Avoid creating artificial unique names - this is unnecessary and clutters code:
// Bad: Unnecessary unique variable names
t1, s1 := qb.NewVar("t1"), qb.NewVar("s1")
t2, s2 := qb.NewVar("t2"), qb.NewVar("s2")
t3, s3 := qb.NewVar("t3"), qb.NewVar("s3")// Build returns (*query.Query, error)
q, err := qb.Query().
Find(name).
Where(qb.Pat(e, PersonName, name)).
Build()
if err != nil {
return err
}
// MustBuild panics on error - useful for static queries
var FindAllPeople = qb.Query().
Find(name).
Where(qb.Pat(e, PersonName, name)).
MustBuild()All database query methods accept both *query.Query and EDN strings:
// Basic execution
results, err := d.Query(q)
results, err := d.Query(q, inputs...)
// Explain query plan
plan, err := d.Explain(q)Query directly into Go structs. With the query builder, fields map positionally to your Find() elements - no tags needed:
// Define attributes
var (
PersonName = qb.Kw(":person/name")
PersonAge = qb.Kw(":person/age")
)
// Result struct - fields map positionally to Find() order
type PersonResult struct {
Name string // maps to first Find() element
Age int64 // maps to second Find() element
}
func FindAdults(d *db.DB) ([]PersonResult, error) {
e := qb.NewVar("e")
name := qb.NewVar("name")
age := qb.NewVar("age")
q := qb.Query().
Find(name, age).
Where(
qb.Pat(e, PersonName, name),
qb.Pat(e, PersonAge, age),
qb.Gte(age, 18),
).
MustBuild()
var results []PersonResult
err := d.QueryInto(&results, q)
return results, err
}For queries that return exactly one result:
func FindPerson(d *db.DB, personName string) (*PersonResult, error) {
e := qb.NewVar("e")
name := qb.NewVar("name")
age := qb.NewVar("age")
q := qb.Query().
Find(name, age).
In(qb.DB, qb.Scalar(name)).
Where(
qb.Pat(e, PersonName, name),
qb.Pat(e, PersonAge, age),
).
MustBuild()
var result PersonResult
found, err := d.QueryOneInto(&result, q, personName)
if err != nil {
return nil, err
}
if !found {
return nil, nil // Not found
}
return &result, nil
}Positional mapping works with aggregations too - fields map by position:
type DeptStats struct {
Dept string // maps to first Find() element (dept)
AvgSalary float64 // maps to second Find() element (avg salary)
Count int64 // maps to third Find() element (count emp)
}
func GetDeptStats(d *db.DB) ([]DeptStats, error) {
emp := qb.NewVar("emp")
dept := qb.NewVar("dept")
salary := qb.NewVar("salary")
q := qb.Query().
Find(dept, qb.Avg(salary), qb.Count(emp)).
Where(
qb.Pat(emp, EmpDept, dept),
qb.Pat(emp, EmpSalary, salary),
).
MustBuild()
var stats []DeptStats
err := d.QueryInto(&stats, q)
return stats, err
}The manual approach above has a subtle problem: variable names in qb.NewVar() must exactly match the datalog struct tags, but nothing enforces this at compile time. Typos cause silent failures.
QueryFor[T] solves this by deriving variables directly from struct tags:
// Define result struct once - tags drive BOTH query building AND result mapping
type PersonResult struct {
Name string `datalog:"?name"`
Age int64 `datalog:"?age"`
}
func FindAdults(d *db.DB) ([]PersonResult, error) {
// QueryFor derives variables from struct tags
q := qb.QueryFor[PersonResult]()
f := &q.F
e := qb.NewVar("e")
query := q.Where(
qb.Pat(e, PersonName, q.Find(&f.Name)), // &f.Name -> ?name
qb.Pat(e, PersonAge, q.Find(&f.Age)), // &f.Age -> ?age
qb.Gt(q.V(&f.Age), 18), // V() references without adding to Find
).MustBuild()
// Results map directly to struct - tags guaranteed to match
var results []PersonResult
err := d.QueryInto(&results, query)
return results, err
}Key methods:
q.Find(&f.Field)- Returns*VarAND adds to Find clauseq.V(&f.Field)- Returns*Varwithout adding to Find (for predicates, join patterns)
Why this is safer:
- Rename struct field → compile error forces you to update all usages
- Typo in
datalogtag → caught when QueryInto tests fail - No string duplication between query and result mapping
With aggregations:
type DeptStats struct {
Dept string `datalog:"?dept"`
Salary int64 `datalog:"?salary"` // base variable
AvgSalary float64 // no tag - positional mapping from Find order
Count int64 // no tag - positional mapping from Find order
Emp int64 `datalog:"?emp"` // base variable
}
func GetDeptStats(d *db.DB) ([]DeptStats, error) {
q := qb.QueryFor[DeptStats]()
f := &q.F
e := qb.NewVar("e")
// V() gives the variable, you wrap it in aggregation
query := qb.Query().
Find(q.V(&f.Dept), qb.Avg(q.V(&f.Salary)), qb.Count(q.V(&f.Emp))).
Where(
qb.Pat(e, EmpDept, q.V(&f.Dept)),
qb.Pat(e, EmpSalary, q.V(&f.Salary)),
qb.Pat(e, EmpID, q.V(&f.Emp)),
).
MustBuild()
var stats []DeptStats
err := d.QueryInto(&stats, query)
return stats, err
}package main
import (
"fmt"
"github.com/wbrown/janus-datalog/datalog/db"
"github.com/wbrown/janus-datalog/datalog/qb"
)
// Define attributes once
var (
PersonName = qb.Kw(":person/name")
PersonAge = qb.Kw(":person/age")
PersonCity = qb.Kw(":person/city")
)
func main() {
d, _ := db.Open("example.db")
defer d.Close()
// Find adults in specific cities
e := qb.NewVar("e")
name := qb.NewVar("name")
age := qb.NewVar("age")
city := qb.NewVar("city")
q := qb.Query().
Find(name, age, city).
In(qb.DB, qb.Collection(city)).
Where(
qb.Pat(e, PersonName, name),
qb.Pat(e, PersonAge, age),
qb.Pat(e, PersonCity, city),
qb.Gte(age, 18),
).
OrderBy(qb.Desc(age)).
MustBuild()
results, err := d.Query(q, []string{"NYC", "LA"})
if err != nil {
panic(err)
}
for _, tuple := range results {
fmt.Printf("%s (%d) - %s\n", tuple[0], tuple[1], tuple[2])
}
}| Type | Purpose | Example |
|---|---|---|
*Var |
Query variable | e := qb.NewVar("e") |
Attr |
Keyword attribute | PersonName := qb.Kw(":person/name") |
Val |
Constant value | qb.V("NYC"), qb.V(42) |
*TypedQueryBuilder[T] |
Type-safe builder | q := qb.QueryFor[PersonResult]() |
| Function | EDN Equivalent | Description |
|---|---|---|
qb.Pat(e, a, v) |
[?e ?a ?v] |
3-element pattern |
qb.Pat(e, a, v, tx) |
[?e ?a ?v ?tx] |
With transaction |
qb.Pat(e, a, v, tx, op) |
[?e ?a ?v ?tx ?op] |
History pattern |
qb.PatFrom(src, e, a, v) |
[$src ?e ?a ?v] |
Source-qualified pattern |
qb.Blank() |
_ |
Wildcard |
| Function | EDN Equivalent | With Binding |
|---|---|---|
qb.Lt(a, b) |
[(< ?a ?b)] |
qb.Lt(a, b).As(result) |
qb.Lte(a, b) |
[(<= ?a ?b)] |
qb.Lte(a, b).As(result) |
qb.Gt(a, b) |
[(> ?a ?b)] |
qb.Gt(a, b).As(result) |
qb.Gte(a, b) |
[(>= ?a ?b)] |
qb.Gte(a, b).As(result) |
qb.Eq(a, b) |
[(= ?a ?b)] |
qb.Eq(a, b).As(result) |
qb.Ne(a, b) |
[(!= ?a ?b)] |
qb.Ne(a, b).As(result) |
qb.Range(lo, x, hi) |
[(< lo ?x hi)] |
qb.Range(lo, x, hi).As(result) |
| Function | EDN Equivalent |
|---|---|
qb.Sum(v) |
(sum ?v) |
qb.Count(v) |
(count ?v) |
qb.Avg(v) |
(avg ?v) |
qb.Min(v) |
(min ?v) |
qb.Max(v) |
(max ?v) |
| Function | EDN Equivalent |
|---|---|
qb.Add(a, b).As(r) |
[(+ ?a ?b) ?r] |
qb.Add(a, b, c).As(r) |
[(+ ?a ?b ?c) ?r] |
qb.Sub(a, b).As(r) |
[(- ?a ?b) ?r] |
qb.Sub(a).As(r) |
[(- ?a) ?r] |
qb.Mul(a, b).As(r) |
[(* ?a ?b) ?r] |
qb.Div(a, b).As(r) |
[(/ ?a ?b) ?r] |
qb.Div(a).As(r) |
[(/ ?a) ?r] |
qb.Str(a, b, c).As(r) |
[(str ?a ?b ?c) ?r] |
qb.Ground(42).As(r) |
[(ground 42) ?r] |
qb.Year(t).As(y) |
[(year ?t) ?y] |
| Function | EDN Equivalent | Description |
|---|---|---|
qb.GetElse(e, attr, default).As(r) |
[(get-else $ ?e :attr default) ?r] |
Default for missing |
qb.Missing(e, attr) |
[(missing? $ ?e :attr)] |
Filter: attr missing |
qb.Missing(e, attr).As(r) |
[(missing? $ ?e :attr) ?r] |
Bind: is attr missing? |
qb.GetSome(e, a1, a2, a3).As(r) |
[(get-some $ ?e :a1 :a2 :a3) ?r] |
First available attr |
| Function | EDN Equivalent | Description |
|---|---|---|
qb.DB |
$ |
Default database source |
qb.Source("$name") |
$name |
Named source |
qb.Scalar(v) |
?v |
Single value |
qb.Collection(v) |
[?v ...] |
Multiple values |
qb.Tuple(a, b) |
[?a ?b] |
Single tuple |
qb.Relation(a, b) |
[[?a ?b] ...] |
Multiple tuples |
| Function | EDN Equivalent |
|---|---|
qb.Not(clauses...) |
(not ...) |
qb.NotJoin(vars, clauses...) |
(not-join [vars] ...) |
qb.Or().Branch(...).Branch(...) |
(or ...) |
qb.OrJoin(vars...).Branch(...).Branch(...) |
(or-join [vars] ...) |
| Function | EDN Equivalent |
|---|---|
qb.Asc(v) |
:asc |
qb.Desc(v) |
:desc |