mirror of
https://github.com/hanzoai/orm.git
synced 2026-10-30 15:01:22 +00:00
* 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.
62 lines
2.0 KiB
Go
62 lines
2.0 KiB
Go
package orm
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
|
|
"github.com/hanzoai/orm/query"
|
|
)
|
|
|
|
// keyRegex is the allowed charset for a column identifier used as a
|
|
// HashExp key. It accepts qualified names (table.column) with segments
|
|
// that start with a letter or underscore and contain only letters,
|
|
// digits, and underscores.
|
|
//
|
|
// Anything else — backticks, quotes, spaces, newlines, NUL, punctuation
|
|
// — is rejected. This is deliberately stricter than SQL-92 to shut the
|
|
// door on the backtick break-out path inherited from
|
|
// dbx.SqliteBuilder.QuoteSimpleColumnName (Red P6-H2).
|
|
var keyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`)
|
|
|
|
// SafeHashExp validates the keys of m against a strict column-identifier
|
|
// regex and returns a query.HashExp on success. An invalid key yields
|
|
// an error identifying the offending input — no partial map is returned.
|
|
//
|
|
// Use SafeHashExp whenever the map keys can come from untrusted input
|
|
// (HTTP query params, user-supplied field names, config flags). For
|
|
// developer-controlled keys, query.HashExp is fine; SafeHashExp is the
|
|
// defense-in-depth boundary between application code and the builder.
|
|
//
|
|
// Background:
|
|
//
|
|
// dbx.SqliteBuilder.QuoteSimpleColumnName passes any string containing
|
|
// a backtick through unchanged, so a key like
|
|
//
|
|
// id`, (SELECT 1), `x
|
|
//
|
|
// is emitted into SQL verbatim as
|
|
//
|
|
// id`, (SELECT 1), `x={:p0}
|
|
//
|
|
// — a SQL injection. SafeHashExp rejects the key before it reaches the
|
|
// builder.
|
|
func SafeHashExp(m map[string]any) (query.HashExp, error) {
|
|
for k := range m {
|
|
if !keyRegex.MatchString(k) {
|
|
return nil, fmt.Errorf("orm: unsafe HashExp key %q: must match %s", k, keyRegex.String())
|
|
}
|
|
}
|
|
return query.HashExp(m), nil
|
|
}
|
|
|
|
// MustSafeHashExp is SafeHashExp that panics on error. Use in static
|
|
// call sites where the key set is known-safe and a runtime error would
|
|
// indicate a programming bug.
|
|
func MustSafeHashExp(m map[string]any) query.HashExp {
|
|
h, err := SafeHashExp(m)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return h
|
|
}
|