Files
orm/docs/sql-query.md
zeekayandGitHub 2b5e204789 feat(orm): v0.5.1 — Typed[T] + SafeHashExp + race-safe chain (Red-VERIFIED) (#1)
* feat(query): re-export dbx SQL AST under orm/query namespace

Every type is an identity alias (type X = dbx.X) and every constructor
is a thin package-level var bound to dbx. Values produced via orm/query
are bit-identical to values produced via dbx and pass through any type
boundary without conversion. Consumers can migrate from hanzoai/dbx to
hanzoai/orm/query file-by-file with zero coordination cost.

* feat: Typed[T] generic wrapper for type-safe SelectQuery binding

Typed[T] is a thin shell around *query.SelectQuery that binds result
rows to a known Go type T. All/One/First/Count terminators replace the
untyped interface{} ceremony in dbx's raw API. orm.Select[T](db, table)
is the common-case constructor; orm.NewTyped[T](sq) wraps any existing
*query.SelectQuery. The escape hatch Typed.Query() exposes the raw
SelectQuery for dbx methods not mirrored on Typed (joins, aggregates,
window functions, per-query hooks).

* test: verify re-export identity and Typed[T] round-trip

query/query_test.go proves:
- type aliases are identity (assignability both directions, reflect
  type equality) so the migration is a no-op at the type system level
- Expression builders produce byte-identical SQL and bound params when
  invoked via dbx or via query (NewExp, Not, And, Or, In, NotIn, Like,
  NotLike, Between, NotBetween, Enclose)
- values cross the dbx / query boundary without conversion

typed_test.go proves:
- All returns []T with correct filtering / ordering
- One returns *T or ErrNotFound on empty
- First returns (T, ok, error) without treating empty as error
- Typed.Query() escape hatch works for unmirrored dbx methods
- NewTyped wraps any externally-built SelectQuery

* docs: SQL query guide + v0.5.0 changelog

docs/sql-query.md explains when to use orm.Typed[T] vs orm/query
directly, gives the two-sed migration recipe from hanzoai/dbx, and
lists what is intentionally not re-exported (the //go:build ignore
compat layer).

CHANGELOG.md flags v0.4.0 existed as a KV/entity-only release; v0.5.0
is the additive SQL slice that unblocks hanzoai/base migrating off
direct dbx imports.

* chore: bump hanzoai/dbx to v1.16.0

Pulls in SelectQuery.Copy() — the deep-copy primitive used by
Typed[T] to keep Count() non-mutating and chain methods race-safe
(Red P6-C1, P6-H1).

* fix(typed): race-safe immutable chain, non-mutating Count, ctx plumbed

Typed[T] is now effectively immutable. Every chain method (Where,
AndWhere, OrWhere, OrderBy, Limit, Offset) returns a Typed[T] wrapping
a CLONE of the underlying *SelectQuery. Sharing a *Typed[T] across
goroutines is safe — no terminator or chain method mutates the
receiver.

Concrete fixes vs Red Part 6:

- P6-C1 (CRITICAL): Count() rewrites SELECT to COUNT(*) on a clone, so
  the caller's original projection is intact for subsequent
  .All() / .One() calls. The pagination pattern
  (tq := ...; tq.Count(); tq.All()) returns real rows, not zero-valued
  garbage. Guarded by TestCountDoesNotMutateQuery.

- P6-H1 (HIGH): concurrent .All() + .Count() on a shared *Typed[T] is
  race-free. Each terminator operates on a fresh clone; the receiver's
  *SelectQuery is never touched by the chain. Guarded by
  TestTypedConcurrentSafe under -race.

- P6-M2 (MEDIUM): every terminator threads the passed ctx via
  *SelectQuery.WithContext so cancellation and deadlines reach the
  driver. No API change.

- P6-M3 (MEDIUM): orm.Select[T](nil, ...) and orm.NewTyped[T](nil) no
  longer panic — they return a Typed[T] carrying ErrNilDB that every
  terminator surfaces as an error. Guarded by TestSelectNilDBReturnsError
  and TestNewTypedNilQuery.

Typed[T].Clone() is added as a public escape hatch for callers that
reach into Typed[T].Query() and want a mutation-isolated copy.

Depends on hanzoai/dbx v1.16.0 for SelectQuery.Copy().

Test: go test -race -count=5 ./... passes clean (zero races, zero
fails).

* feat(orm): SafeHashExp validates keys against injection (P6-H2)

dbx.SqliteBuilder.QuoteSimpleColumnName passes any string containing a
backtick through unchanged, so a query.HashExp key like

    id\`, (SELECT 1), \`x

builds to

    id\`, (SELECT 1), \`x = {:p0}

— a SQL injection. SafeHashExp is the defense-in-depth boundary for
untrusted column identifiers: keys must match

    ^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$

anything else is rejected with a clear error identifying the offending
key. Values remain unvalidated because dbx parameterizes them — the
injection surface is exclusively in the keys.

MustSafeHashExp is the panicking flavor for static call sites where
invalid keys are a programming bug.

query.HashExp continues to exist unchanged for developer-controlled
keys. Application code that reads column names from HTTP query strings
or config must go through SafeHashExp.

Test: TestSafeHashExpRejectsInjection covers the 4 Red-proof payloads
plus space/NUL/newline/empty variants. TestSafeHashExpRoundTrip proves
the valid path still builds correct SQL.

* docs: v0.5.1 changelog, SafeHashExp guidance, softened dbx language (P6-H3)

- CHANGELOG.md gains a v0.5.1 section summarizing the P6-C1 / P6-H1 /
  P6-H2 / P6-M2 / P6-M3 / P6-H3 fixes and the dbx v1.16.0 bump.

- CHANGELOG.md v0.5.0 "Deprecated" section is reframed as "Migration
  posture": direct hanzoai/dbx imports are not deprecated and continue
  to work; new code should prefer orm/query. This matches the reality
  that base/feat/orm-query-seam still has 68 files importing dbx
  directly and a hard deprecation would make those files instantly
  non-conforming without a tracked migration PR (Red P6-H3).

- docs/sql-query.md gains a "HashExp keys — developer-controlled only"
  section with the backtick-breakout attack payload and the
  SafeHashExp remediation pattern. The previous blanket "do not
  import hanzoai/dbx directly from application code" is softened to
  "new code should import orm/query".

* test: re-register init()-bound types after resetRegistry

TestResetRegistry wipes the model registry to exercise the teardown
path, then re-registers every type it knows about. Three types were
missing: intgUser / intgHooked (adapter_test.go init()) and
UpdateHookedModel (gaps_test.go init()). Without them, running the
full suite under -count>=2 hit a "type not registered" panic in
TestSQLiteAdapterCreate and TestUpdateHookReceivesOldState.

Pre-existing bug exposed while satisfying -race -count=5 on the
P6 regression gate.
2026-04-21 13:04:38 -07:00

5.6 KiB

orm SQL query layer

hanzoai/orm ships two complementary SQL surfaces:

  • orm/query — a faithful re-export of the SQL AST primitives from hanzoai/dbx. Every type is an identity alias (type X = dbx.X) and every constructor is a thin var binding to the dbx package function. Use this when you need the full expressive power of dbx (joins, unions, subqueries, window functions, hooks, pooled connections).

  • orm.Typed[T] — a generic wrapper that binds *query.SelectQuery result rows to a Go type T. Use this when you know the row shape at compile time and want type-safe All / One / First / Count terminators without the untyped interface{} ceremony.

There is one and only one way to do each: for dynamic / structural queries reach for orm/query directly; for typed result binding reach for orm.Typed[T]. New code should import orm/query rather than hanzoai/dbx — the dbx module is the canonical SQL AST and continues to work if already imported, but the single idiomatic path for new callers is orm/query.

When to use orm.Typed[T]

Known model shape, most common case:

import (
    "github.com/hanzoai/orm"
    "github.com/hanzoai/orm/query"
)

type User struct {
    ID     string `db:"id"`
    Email  string `db:"email"`
    Active bool   `db:"active"`
}

users, err := orm.Select[User](db, "users").
    Where(query.HashExp{"active": true}).
    OrderBy("email").
    Limit(50).
    All(ctx)

For a single row:

u, err := orm.Select[User](db, "users").
    Where(query.HashExp{"id": id}).
    One(ctx) // returns orm.ErrNotFound if empty

For optional lookup without treating empty as an error:

u, ok, err := orm.Select[User](db, "users").
    Where(query.HashExp{"id": id}).
    First(ctx)

When to use orm/query directly

Dynamic queries, cross-table joins, aggregates that don't map cleanly to a single T, or when you need dbx-specific features not mirrored on Typed:

import "github.com/hanzoai/orm/query"

var total int64
err := db.Select("COUNT(*)").
    From("orders").
    Where(query.And(
        query.HashExp{"status": "paid"},
        query.Between("created_at", from, to),
    )).
    Row(&total)

For joins, reach for the escape hatch on Typed when you still want typed binding:

tq := orm.Select[Invoice](db, "invoices")
tq.Query().
    InnerJoin("customers", query.NewExp("customers.id = invoices.customer_id")).
    AndWhere(query.HashExp{"customers.region": "us"})

rows, err := tq.All(ctx)

Migrating from direct hanzoai/dbx imports

orm/query is a drop-in replacement. Every symbol used from dbx has a same-named counterpart in orm/query and every value is bit-identical — generated SQL, bound params, and method sets match exactly.

The mechanical rewrite for a package is two sed expressions:

# 1) flip the import path
sed -i '' 's|"github.com/hanzoai/dbx"|"github.com/hanzoai/orm/query"|g' *.go

# 2) flip the package-qualifier prefix
#    (safe because no orm/query identifier collides with a Go keyword
#    or a common local symbol — the prefix is unambiguous in Go source)
sed -i '' 's|\bdbx\.|query.|g' *.go

# 3) let goimports resolve any remaining formatting / alias
goimports -w .

Because query.SelectQuery = dbx.SelectQuery by identity, a migrated package continues to interoperate with unmigrated callers that still pass *dbx.SelectQuery values. Migration can proceed file-by-file with zero coordination cost.

Escape hatch: accessing the raw SelectQuery

Not every dbx method is mirrored on Typed. When you need to apply one of the unmirrored methods (e.g. Distinct, GroupBy, Having, LeftJoin, per-query hooks), reach through Typed.Query():

tq := orm.Select[Row](db, "events")
tq.Query().
    Distinct(true).
    GroupBy("user_id").
    Having(query.NewExp("COUNT(*) > {:n}", query.Params{"n": 10}))

rows, err := tq.All(ctx)

HashExp keys — developer-controlled only

query.HashExp is a map sugar for equality-AND clauses. Its keys are concatenated verbatim into the generated SQL after a column-quote pass. The dbx column-quoter preserves the input unchanged when it already contains a backtick — so a key like

query.HashExp{"id`, (SELECT 1), `x": 1}

builds to

id`, (SELECT 1), `x = {:p0}

which is an injected fragment. Do not pass untrusted strings as HashExp keys. Every key in a HashExp literal in application code should be a compile-time column identifier.

When a key can come from untrusted input (HTTP query params, user filter UIs, config flags), use orm.SafeHashExp:

h, err := orm.SafeHashExp(map[string]any{
    userSuppliedColumn: userSuppliedValue,
})
if err != nil {
    return fmt.Errorf("unsafe filter: %w", err)
}
rows, err := orm.Select[User](db, "users").Where(h).All(ctx)

SafeHashExp rejects any key that does not match ^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$ — no backticks, quotes, spaces, or punctuation. Values are not validated by SafeHashExp because dbx parameterizes them; the injection surface is exclusively in the keys.

For static call sites where the keys are known-safe literals, use orm.MustSafeHashExp to assert the invariant at construction time.

What is not re-exported

  • dbx.CompatEngine and the xorm-compat layer are behind //go:build ignore in the dbx repo and intentionally excluded. New code should not use them.
  • dbx.RawExpression lives in the same ignored compat layer and is not part of the public surface.
  • Internal structs like structValue, structInfo, columnDef remain unexported in dbx and therefore unreachable through either package.