Files
orm/db/sqlite_index.go
zeekayandHanzo Dev 101c90e8dc SQLite: a field tagged orm:"index" is a partial index of its kind, and the query picks it
A filter was json_extract over every live row of every kind: an IAM email lookup took 419 ms at 150,000 users, a token lookup 143 ms, linear in the table. Each tagged field now has idx_<kind>_<path> ON _entities(json_extract(...)) WHERE kind = '<kind>' AND deleted = 0, built in the background on the kind's first query. Statements spell the kind as a literal so the partial index applies, and the last equality filter on an indexed field drives; other indexed terms are written +expr, because with no statistics SQLite chose among the indexes by creation order. idx_entities_deleted is dropped: every unindexed statement walked it across all kinds. Measured with IAM's own store at 1k/10k/50k/150k/300k users: email lookups 1.2/0.9/1.4/1.6/1.5 ms (were 2.8/29/139/419/831), token lookups 0.7/0.6/1.4/1.2/0.9 ms (were 1.0/11/49/143/283).

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-02 09:41:01 -07:00

159 lines
5.6 KiB
Go

package db
import (
"fmt"
"maps"
"strings"
)
// sqlite_index.go makes a field a model tags orm:"index" a real SQLite index.
//
// Every entity lives in one table, `_entities`, as a JSON document, so a filter is
// `json_extract(data, '$.field') = ?`. Without an index on that expression the
// statement reads every live row of every kind: an IAM token lookup cost 143 ms at
// 150,000 users and an email lookup 419 ms, both linear in the table.
//
// Each tagged field gets a PARTIAL expression index scoped to its kind:
//
// CREATE INDEX "idx_tokens_accessTokenHash" ON _entities(json_extract(data, '$.accessTokenHash'))
// WHERE kind = 'tokens' AND deleted = 0
//
// Partial, because the table holds every kind: an index over all rows would carry an
// entry for every entity in every index, and each insert would pay for all of them.
// SQLite uses a partial index only when the statement's WHERE clause states the same
// terms, which is why every statement spells the kind as a literal (kindLiteral) and
// keeps `deleted = 0` as written. A bound `kind = ?` cannot be proven to equal the
// literal at prepare time, and the planner then ignores the index.
//
// The planner is not asked to choose between two of these indexes. The table has no
// statistics, and without them SQLite picks among equally costed indexes by creation
// order: an `owner` index, which matches every user of an org, beat the `email`
// index that matches one. drivingFilter picks the index from the query instead, and
// the other indexed terms are written with a unary + so they filter without driving.
// indexPrefix names every index this file creates.
const indexPrefix = "idx_"
// Index gives each JSON path of kind its own partial index. It is idempotent: a path
// already indexed in this process is skipped, and CREATE INDEX IF NOT EXISTS makes a
// second process, or a restart, a no-op on the file. Building an index over an
// existing table reads the table once, so the first call on a large store takes
// seconds; the write lock is taken per index so writes interleave with the build.
//
// Index must not be called by a goroutine that holds the write lock (inside a
// transaction): it waits for that lock.
func (db *SQLiteDB) Index(kind string, paths []string) error {
db.indexMu.Lock()
defer db.indexMu.Unlock()
have := db.indexed.Load()
var missing []string
for _, p := range paths {
p = ToJSONFieldName(p)
if p != "" && !(*have)[kind][p] && !containsString(missing, p) {
missing = append(missing, p)
}
}
for _, p := range missing {
db.writeMu.Lock()
_, err := db.writeDB.Exec(indexDDL(kind, p))
db.writeMu.Unlock()
if err != nil {
return fmt.Errorf("db: index %s.%s: %w", kind, p, err)
}
next := maps.Clone(*db.indexed.Load())
next[kind] = maps.Clone(next[kind])
if next[kind] == nil {
next[kind] = map[string]bool{}
}
next[kind][p] = true
db.indexed.Store(&next)
}
return nil
}
// indexedPaths is the set of JSON paths of kind that have an index. Statements read
// it without a lock: Index publishes a new map instead of changing the one a
// statement may be reading.
func (db *SQLiteDB) indexedPaths(kind string) map[string]bool {
return (*db.indexed.Load())[kind]
}
// indexDDL is the statement that indexes one path of one kind. Its expression and
// its WHERE clause are the text the query builder writes, character for character:
// SQLite matches an index to a statement by comparing the two.
func indexDDL(kind, path string) string {
return fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s ON _entities(%s) WHERE %s`,
quoteIdent(indexPrefix+kind+"_"+path), jsonExpr(path), kindClause(kind))
}
// jsonExpr is the expression a JSON path is read and indexed through.
func jsonExpr(path string) string {
return fmt.Sprintf("json_extract(data, '$.%s')", path)
}
// kindClause is the leading condition of every statement over one kind, and the
// WHERE clause of every index on it.
func kindClause(kind string) string {
return "kind = " + kindLiteral(kind) + " AND deleted = 0"
}
// kindLiteral writes kind as a SQL string literal: quotes doubled, NUL dropped (a
// NUL would end the statement text early). Kinds are model names a program
// registers, but they are escaped rather than trusted.
func kindLiteral(kind string) string {
kind = strings.ReplaceAll(kind, "\x00", "")
return "'" + strings.ReplaceAll(kind, "'", "''") + "'"
}
// quoteIdent writes name as a SQL identifier.
func quoteIdent(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
// drivingFilter is the index of the filter that drives a query over an indexed
// kind, or -1 for none: the LAST equality filter on an indexed path, else the last
// range filter on one. Callers narrow last — Filter("Owner=", org).Filter("Email=",
// e) — so the last filter is the selective one. `!=` never drives; it cannot.
func drivingFilter(filters []QueryFilter, indexed map[string]bool) int {
pick := -1
for i, f := range filters {
if !indexed[ToJSONFieldName(f.Field)] || zeroEquality(f) {
continue
}
switch f.Op {
case "=":
pick = i
case ">", ">=", "<", "<=":
if pick < 0 || filters[pick].Op != "=" {
pick = i
}
}
}
return pick
}
// zeroEquality reports a filter written as COALESCE(...) = 0 so that an absent field
// matches false or 0. That expression is not the indexed one and cannot drive.
func zeroEquality(f QueryFilter) bool {
if f.Op != "=" {
return false
}
switch v := f.Value.(type) {
case bool:
return !v
case int:
return v == 0
}
return false
}
func containsString(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}