* 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.
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 fromhanzoai/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.SelectQueryresult rows to a Go typeT. Use this when you know the row shape at compile time and want type-safeAll / One / First / Countterminators without the untypedinterface{}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.CompatEngineand the xorm-compat layer are behind//go:build ignorein the dbx repo and intentionally excluded. New code should not use them.dbx.RawExpressionlives in the same ignored compat layer and is not part of the public surface.- Internal structs like
structValue,structInfo,columnDefremain unexported in dbx and therefore unreachable through either package.