Skip to content

Latest commit

 

History

History
183 lines (125 loc) · 4.36 KB

File metadata and controls

183 lines (125 loc) · 4.36 KB

Pointer-Tracking DSL

The query DSL is the heart of go-wormhole. It gives you compile-time type safety on queries without any code generation step.

The Problem

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.

How It Works

Boot Time (once)

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.

Runtime (hot path, zero-alloc)

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.

Why This is Brilliant

Full Type Safety

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).

Refactor Proof

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.

No Code Generation

Everything happens in memory at boot time. No go generate, no extra build step, no .gen.go files to commit.

Available Operators

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 NULL

All operators return a query.Predicate — a neutral AST node that the SQL compiler or Slipstream provider can translate independently.

Composing Conditions

AND (implicit)

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" = ?

OR (explicit)

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" = ?)

Generic API

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)

Important: Register at Boot

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{})
}