The query DSL is the heart of go-wormhole. It gives you compile-time
type safety on queries without any code generation step.
Every Go ORM eventually faces the same dilemma:
// Magic strings — no compiler help, breaks silently on rename
db.Where("age > ?", 18)
// Code generation — extra build step, stale files, CI complexity
user.Age.Gt(18) // generated by `go generate`go-wormhole solves this with a third approach: pointer arithmetic at
runtime, pre-computed at boot time.
dsl.Register(User{}) iterates every field of the struct, records its
memory offset from the struct base address, and maps it to the column name:
// Simplified view of what Register does internally
func Register(proto any) {
t := reflect.TypeOf(proto)
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
offset := sf.Offset // e.g. 16 bytes
column := parseColumnName(sf) // e.g. "age"
registry[t][offset] = &fieldInfo{Column: column}
}
}This runs once at startup. The cost is negligible.
When you write dsl.Gt(&u, &u.Age, 18), the DSL computes:
func resolve[B any, F any](base *B, fieldPtr *F) *fieldInfo {
baseAddr := uintptr(unsafe.Pointer(base)) // address of struct
fieldAddr := uintptr(unsafe.Pointer(fieldPtr)) // address of field
offset := fieldAddr - baseAddr // = sf.Offset
return registry[typeof(B)][offset] // O(1) map lookup
}No reflection. No allocation. Just pointer subtraction and a map lookup.
If u.Age is an int, dsl.Gt expects an int.
Writing dsl.Gt(&u, &u.Age, "18") will not compile:
cannot use "18" (untyped string constant) as int value
This is enforced by Go's dual-generic signature: func Gt[B any, F any](base *B, fieldPtr *F, val F).
If you rename u.Age to u.YearsOld in your IDE, every query using
&u.Age updates automatically. No stale generated code, no broken strings.
Everything happens in memory at boot time. No go generate, no extra
build step, no .gen.go files to commit.
u := &User{}
// Equality
dsl.Eq(&u, &u.Name, "Alice") // name = 'Alice'
dsl.Neq(&u, &u.Status, "banned") // status != 'banned'
// Comparison
dsl.Gt(&u, &u.Age, 18) // age > 18
dsl.Gte(&u, &u.Age, 18) // age >= 18
dsl.Lt(&u, &u.Age, 65) // age < 65
dsl.Lte(&u, &u.Age, 65) // age <= 65
// Set membership
dsl.In(&u, &u.Role, "admin", "mod") // role IN ('admin', 'mod')
// Pattern matching (string fields only)
dsl.Like(&u, &u.Email, "%@go.dev") // email LIKE '%@go.dev'
dsl.Contains(&u, &u.Name, "ali") // name LIKE '%ali%'
// Null checks
dsl.IsNil(&u, &u.DeletedAt) // deleted_at IS NULLAll operators return a query.Predicate — a neutral AST node that
the SQL compiler or Slipstream provider can translate independently.
Pass multiple predicates to Where:
ctx.Set(&users).
Where(
dsl.Gt(&u, &u.Age, 18),
dsl.Eq(&u, &u.Active, true),
).
All()Produces: WHERE "age" > ? AND "active" = ?
Use query.Or() to compose:
ctx.Set(&users).
Where(query.Or(
dsl.Eq(&u, &u.Role, "admin"),
dsl.Eq(&u, &u.Role, "moderator"),
)).
All()Produces: WHERE ("role" = ? OR "role" = ?)
For fully type-safe return values, use the generic functions:
import wh "github.com/fabricatorsltd/go-wormhole/pkg/context"
// Single entity by PK — returns *User, not any
user, err := wh.Find[User](ctx, dbCtx, 42)
// Query — returns []User, not []any
users, err := wh.Query[User](dbCtx).
Where(dsl.Gt(&u, &u.Age, 18)).
Limit(10).
Exec(ctx)You must call dsl.Register(T{}) for every entity type before using the DSL.
Failing to do so will cause a panic at runtime:
panic: dsl: type not registered — call dsl.Register first
The idiomatic place is an init() function in your models package:
func init() {
dsl.Register(User{})
dsl.Register(Order{})
dsl.Register(Product{})
}