// Package dbutil provides a SQL query builder for PostgreSQL. // // The builder generates parameterized SQL strings and argument slices from // composable Go values. It is not an ORM. It does not manage connections, // transactions, or migrations. It pairs with the automapper in automapper.go // to scan query results into structs. // // # Naming conventions // // The public API uses short names because they appear repeatedly in query // construction code: // // - T (Table) creates a typed table reference from a model struct. // - M (Model) is the addressable zero-value of the model struct living on // the table reference. It exists solely so you can take field pointers // for F and FieldNames. It does not hold real data. // - F (Field) resolves a pointer to a field on M into a Col. // - C (Column) creates a Col from a raw column name string. // // # Table references // // Every query starts by binding a Go model type to a SQL table alias with T: // // au := T[models.AppUser]("au") // // T looks up the table name from models.Tables, allocates an addressable // zero-value of the model (stored in au.M), and builds a mapping from struct // field byte offsets to their "db" tag values. The type parameter gives you // compile-time safety; the alias is the SQL alias used in the generated query. // Omit the alias for single-table statements: // // it := T[models.Identity]() // uses the bare table name "identity" // // For tables not registered in models.Tables (CTEs, subquery aliases, etc.), // use TName: // // cte := TName("recent_logins", "rl") // // # Referencing columns // // F takes a pointer to a field on the table's zero-value M and resolves it to // the column name from its "db" tag. This gives you IDE autocomplete and // compile-time breakage when a field is renamed or removed: // // au.F(&au.M.ID) // Col representing "au.id" // au.F(&au.M.FirstName) // Col representing "au.first_name" // // Under the hood F computes the pointer's byte offset relative to au.M and // looks it up in a cached offset-to-column map. // // C is still available as a raw-string fallback for expressions that don't // correspond to a single struct field: // // au.C("id") // same as au.F(&au.M.ID), but no compile-time checking // // FieldNames does the same resolution as F but returns bare column name // strings instead of Col values. Use it with SetColumns and Columns: // // au.FieldNames(&au.M.FirstName, &au.M.LastName) // []string{"first_name", "last_name"} // // # Building conditions // // Col methods produce Cond values that carry a SQL fragment and bound args: // // au.F(&au.M.ID).Eq(userID) // "au.id = ?" // au.F(&au.M.LastName).Like("%smith%") // "au.last_name LIKE ?" // au.F(&au.M.ID).In(ids) // "au.id IN (?, ?, ...)" // // Conditions compose with And, Or, and Not: // // cond := au.F(&au.M.Email).IsNotNull().And(au.F(&au.M.LoginCount).Gt(0)) // // EqCol compares two columns without a bound parameter (useful for joins): // // au.F(&au.M.ID).EqCol(ou.F(&ou.M.AppUserID)) // // # SELECT // // Select(au.ColsFlat()). // From(au). // Where(au.F(&au.M.ID).Eq(userID)). // QueryRow(ctx, db, &user) // // ColsFlat generates unaliased column expressions (au.id, au.email, ...) for // single-table queries. Cols generates aliased expressions // (au.id AS "app_user.id", ...) for multi-table queries where the automapper // needs prefixes to route columns into nested destination structs. // // Joins, ordering, grouping, limit, and offset chain as expected: // // Select(ou.Cols(), au.Cols()). // From(ou). // InnerJoin(au, au.F(&au.M.ID).EqCol(ou.F(&ou.M.AppUserID))). // Where(ou.F(&ou.M.OrgID).Eq(orgID)). // OrderBy(au.F(&au.M.LastName).Asc()). // Limit(25). // Offset(50). // Query(ctx, db, &results) // // When joining the same model twice, use MapAs to set a distinct automapper // prefix so the scanner can tell the two apart: // // cb := T[models.AppUser]("cb").MapAs("created_by") // // # INSERT // // InsertInto(it). // Columns(it.FieldNames(&it.M.Key, &it.M.AppUserID, &it.M.Timezone)...). // Model(identity). // Exec(ctx, db) // // If Columns is omitted and Model is provided, all "db"-tagged fields are // inserted. Values can be passed directly with Values() instead of Model(). // // # UPDATE // // Update(au). // SetColumns(au.FieldNames(&au.M.FirstName, &au.M.LastName)...). // Model(user). // Where(au.F(&au.M.ID).Eq(user.ID)). // Exec(ctx, db) // // Set can also be called for individual column/value pairs: // // Update(au).Set("login_count", newCount).Where(...).Exec(ctx, db) // // # DELETE // // DeleteFrom(it). // Where(it.F(&it.M.Key).Eq(key)). // Exec(ctx, db) // // # Execution // // Build returns the final SQL string (with $1, $2, ... placeholders) and the // argument slice. Query, QueryRow, QueryScalarTo, and Exec are convenience // methods that call Build and then execute against a Querier or Execer. // // //////////////////////////////////////////////////////////////////////////// // // BEHIND THE SCENES POINTER MAGIC: // // # How F resolves field pointers to column names // // When T[M] is called, it allocates a zero-value of the model struct with // new(M) and stores the pointer in the M field. It also walks the struct's // reflect.Type and records every db-tagged field's byte offset (from // reflect.StructField.Offset) alongside its "db" tag value into a map: // // fieldMap[0] = "id" // ID is at byte offset 0 // fieldMap[16] = "username" // Username is at byte offset 16 // fieldMap[32] = "email" // Email is at byte offset 32 // ... // // When you call au.F(&au.M.ID), F receives a pointer to the ID field within // that same heap-allocated struct. It subtracts the base address of the struct // from the field's address to recover the byte offset: // // offset = reflect.ValueOf(&au.M.ID).Pointer() - reflect.ValueOf(au.M).Pointer() // // That offset is looked up in fieldMap to get the column name "id", which is // then combined with the table alias to produce the Col expression "au.id". // // This works because Go guarantees that struct fields sit at fixed offsets // from the start of the struct, and those offsets are the same for every // instance of that type. The reflect package exposes them without needing // an unsafe import. // // If the pointer does not fall within the struct (e.g. you pass a pointer to // an unrelated variable), the offset will not exist in the map and F panics. // // # Field offset cache // // Walking a struct's reflect.Type to collect field offsets is not free, but // the result is the same for every instance of a given type. The offset map // is computed once and stored in a package-level cache keyed by reflect.Type: // // var fieldCache = map[reflect.Type]map[uintptr]string // // The cache is protected by a sync.RWMutex using a double-check pattern. // On the hot path (the type has been seen before), buildFieldOffsetMap takes // a read lock, finds the map, and returns it. On the cold path (first time // seeing a type), it upgrades to a write lock, checks again in case another // goroutine populated it in the meantime, and only then does the reflect // walk. This is the same pattern used by the automapper's mappingCache in // automapper.go. // // Each call to T[M] receives a reference to the shared cached map rather // than its own copy, so there is no per-table-reference allocation cost // beyond the first time a model type is used. // // See examples.go for full working examples of each pattern. package dbutil import ( "context" "database/sql" "fmt" "reflect" "strings" "sync" ) // AllColumns is a convenience constant for use with Returning(). const AllColumns = "*" type Execer interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) } // TableExpr is satisfied by both TableRef and Tbl[M]. type TableExpr interface { tableRef() TableRef } // TableRef type TableRef struct { tableName string alias string modelType reflect.Type mapPrefix string } func (t TableRef) tableRef() TableRef { return t } // fieldInfo stores the column name and Go type for a struct field. type fieldInfo struct { colName string fieldType reflect.Type } // Tbl[M] wraps TableRef and adds type-safe field references. type Tbl[M any] struct { TableRef M *M fieldMap map[uintptr]fieldInfo } func T[M any](alias ...string) Tbl[M] { var zero M t := reflect.TypeOf(zero) if t.Kind() == reflect.Ptr { t = t.Elem() } tableName := tableNameFor(t) if tableName == "" { panic(fmt.Sprintf("builder: no table registered for type %s", t.Name())) } a := "" if len(alias) > 0 { a = alias[0] } tbl := Tbl[M]{ TableRef: TableRef{ tableName: tableName, alias: a, modelType: t, mapPrefix: toSnakeCase(t.Name()), }, } tbl.M = new(M) tbl.fieldMap = buildFieldOffsetMap(t) return tbl } func (t Tbl[M]) tableRef() TableRef { return t.TableRef } // F resolves a pointer to a field on t.M to a Col using byte offset math. func (t Tbl[M]) F(fieldPtr any) Col { ptr := reflect.ValueOf(fieldPtr).Pointer() base := reflect.ValueOf(t.M).Pointer() offset := ptr - base info, ok := t.fieldMap[offset] if !ok { panic(fmt.Sprintf("builder: field pointer offset %d not found in %T", offset, *t.M)) } return Col{expr: t.ref() + "." + info.colName, fieldType: info.fieldType} } // FieldNames resolves multiple field pointers to their db column names. func (t Tbl[M]) FieldNames(fieldPtrs ...any) []string { base := reflect.ValueOf(t.M).Pointer() names := make([]string, len(fieldPtrs)) for i, fp := range fieldPtrs { ptr := reflect.ValueOf(fp).Pointer() offset := ptr - base info, ok := t.fieldMap[offset] if !ok { panic(fmt.Sprintf("builder: field pointer offset %d not found in %T", offset, *t.M)) } names[i] = info.colName } return names } // As returns a copy with a new alias. func (t Tbl[M]) As(alias string) Tbl[M] { t.TableRef = t.TableRef.As(alias) return t } // MapAs returns a copy with a new automapper prefix. func (t Tbl[M]) MapAs(prefix string) Tbl[M] { t.TableRef = t.TableRef.MapAs(prefix) return t } // Field offset cache var ( fieldCacheMu sync.RWMutex fieldCache = make(map[reflect.Type]map[uintptr]fieldInfo) ) func buildFieldOffsetMap(t reflect.Type) map[uintptr]fieldInfo { fieldCacheMu.RLock() if m, ok := fieldCache[t]; ok { fieldCacheMu.RUnlock() return m } fieldCacheMu.RUnlock() fieldCacheMu.Lock() defer fieldCacheMu.Unlock() if m, ok := fieldCache[t]; ok { return m } m := make(map[uintptr]fieldInfo) walkFieldOffsets(t, 0, m) fieldCache[t] = m return m } func walkFieldOffsets(t reflect.Type, base uintptr, m map[uintptr]fieldInfo) { for i := range t.NumField() { f := t.Field(i) if !f.IsExported() { continue } if dbTag := f.Tag.Get("db"); dbTag != "" && dbTag != "-" { ft := f.Type if ft.Kind() == reflect.Ptr { ft = ft.Elem() } m[base+f.Offset] = fieldInfo{colName: dbTag, fieldType: ft} continue } if f.Anonymous { ft := derefType(f.Type) if ft.Kind() == reflect.Struct { walkFieldOffsets(ft, base+f.Offset, m) } } } } func TName(tableName string, alias ...string) TableRef { a := "" if len(alias) > 0 { a = alias[0] } return TableRef{tableName: tableName, alias: a} } func (t TableRef) As(alias string) TableRef { t.alias = alias return t } // MapAs overrides the automapper column prefix. // Use when joining the same table twice with different roles: // // cb := T[models.AppUser]("cb").MapAs("created_by") func (t TableRef) MapAs(prefix string) TableRef { t.mapPrefix = prefix return t } func (t TableRef) C(name string) Col { return Col{expr: t.ref() + "." + name} } // C creates a Col from a bare name, not tied to any table. // Use for computed aliases in ORDER BY / GROUP BY (e.g. C("points")). func C(name string) Col { return Col{expr: name} } // Cols returns automapper-compatible aliased columns for JOIN queries. // // T(models.AppUser{}, "au").Cols() // -> au.id AS "app_user.id", au.username AS "app_user.username", ... func (t TableRef) Cols() string { if t.modelType == nil { panic("builder: TableRef has no model type, cannot generate columns") } model := reflect.New(t.modelType).Interface() return Columns(model, t.ref(), t.mapPrefix) } // ColsFlat returns unaliased column expressions for single-table queries. // // T(models.AppUser{}, "au").ColsFlat() // -> au.id, au.username, au.email, ... func (t TableRef) ColsFlat() string { if t.modelType == nil { panic("builder: TableRef has no model type, cannot generate columns") } cols := collectColumnsFlat(t.modelType, t.ref()) return strings.Join(cols, ", ") } func (t TableRef) AllColNames() []string { if t.modelType == nil { panic("builder: TableRef has no model type") } return allDBColumns(t.modelType) } func (t TableRef) ref() string { if t.alias != "" { return t.alias } return t.tableName } func (t TableRef) fromExpr() string { if t.alias != "" { return t.tableName + " " + t.alias } return t.tableName } // Col type Col struct { expr string alias string // set by RawCol; empty for normal columns fieldType reflect.Type // set by F(); nil for raw/computed columns } // String returns the column expression for use in SELECT lists. // For RawCol columns, this includes the AS "alias" suffix. func (c Col) String() string { if c.alias != "" { return c.expr + ` AS "` + c.alias + `"` } return c.expr } // checkType validates that val's type matches the column's field type. // Panics on mismatch. Skips check if fieldType is nil (raw/computed columns). func (c Col) checkType(val any) { if c.fieldType == nil || val == nil { return } valType := reflect.TypeOf(val) if valType != c.fieldType { panic(fmt.Sprintf( "dbutil: type mismatch for column %s: expected %s, got %s (%v)", c.expr, c.fieldType, valType, val, )) } } func (c Col) Eq(val any) Cond { c.checkType(val) return Cond{fragment: c.expr + " = ?", args: []any{val}} } func (c Col) Neq(val any) Cond { c.checkType(val) return Cond{fragment: c.expr + " <> ?", args: []any{val}} } func (c Col) Gt(val any) Cond { c.checkType(val) return Cond{fragment: c.expr + " > ?", args: []any{val}} } func (c Col) GtEq(val any) Cond { c.checkType(val) return Cond{fragment: c.expr + " >= ?", args: []any{val}} } func (c Col) Lt(val any) Cond { c.checkType(val) return Cond{fragment: c.expr + " < ?", args: []any{val}} } func (c Col) LtEq(val any) Cond { c.checkType(val) return Cond{fragment: c.expr + " <= ?", args: []any{val}} } func (c Col) Like(val any) Cond { return Cond{fragment: c.expr + " LIKE ?", args: []any{val}} } func (c Col) ILike(val any) Cond { return Cond{fragment: c.expr + " ILIKE ?", args: []any{val}} } func (c Col) IsNull() Cond { return Cond{fragment: c.expr + " IS NULL"} } func (c Col) IsNotNull() Cond { return Cond{fragment: c.expr + " IS NOT NULL"} } func (c Col) EqCol(other Col) Cond { return Cond{fragment: c.expr + " = " + other.expr} } func (c Col) GtCol(other Col) Cond { return Cond{fragment: c.expr + " > " + other.expr} } func (c Col) LtCol(other Col) Cond { return Cond{fragment: c.expr + " < " + other.expr} } func (c Col) Between(low, high any) Cond { c.checkType(low) c.checkType(high) return Cond{fragment: c.expr + " BETWEEN ? AND ?", args: []any{low, high}} } // In accepts individual values or a single slice argument. func (c Col) In(vals ...any) Cond { if len(vals) == 1 { rv := reflect.ValueOf(vals[0]) if rv.Kind() == reflect.Slice { expanded := make([]any, rv.Len()) for i := range rv.Len() { expanded[i] = rv.Index(i).Interface() } vals = expanded } } for _, v := range vals { c.checkType(v) } placeholders := make([]string, len(vals)) for i := range vals { placeholders[i] = "?" } return Cond{ fragment: c.expr + " IN (" + strings.Join(placeholders, ", ") + ")", args: vals, } } func (c Col) InQuery(sub *SelectBuilder) Cond { subSQL, subArgs := sub.toSQL() return Cond{ fragment: c.expr + " IN (" + subSQL + ")", args: subArgs, } } func (c Col) Asc() OrderExpr { return OrderExpr{expr: c.expr + " ASC"} } func (c Col) Desc() OrderExpr { return OrderExpr{expr: c.expr + " DESC"} } func Lower(c Col) Col { return Col{expr: "LOWER(" + c.expr + ")"} } func Sum(c Col) Col { return Col{expr: "SUM(" + c.expr + ")"} } func Max(c Col) Col { return Col{expr: "MAX(" + c.expr + ")"} } func Min(c Col) Col { return Col{expr: "MIN(" + c.expr + ")"} } func Count(c Col) Col { return Col{expr: "COUNT(" + c.expr + ")"} } func CountExpr(expr string) Col { return Col{expr: "COUNT(" + expr + ")"} } func CountDistinct(c Col) Col { return Col{expr: "COUNT(DISTINCT " + c.expr + ")"} } func Round(c Col) Col { return Col{expr: "ROUND(" + c.expr + ")"} } // CountDistinctRow produces COUNT(DISTINCT ROW(col1, col2, ...)). func CountDistinctRow(cols ...Col) Col { parts := make([]string, len(cols)) for i, c := range cols { parts[i] = c.expr } return Col{expr: "COUNT(DISTINCT ROW(" + strings.Join(parts, ", ") + "))"} } // Arithmetic operations on columns. func (c Col) Mul(other Col) Col { return Col{expr: "(" + c.expr + " * " + other.expr + ")"} } func (c Col) Div(other Col) Col { return Col{expr: "(" + c.expr + " / " + other.expr + ")"} } func (c Col) Add(other Col) Col { return Col{expr: "(" + c.expr + " + " + other.expr + ")"} } func (c Col) Sub(other Col) Col { return Col{expr: "(" + c.expr + " - " + other.expr + ")"} } // NumLit creates a Col from a numeric literal. func NumLit(val any) Col { return Col{expr: fmt.Sprintf("%v", val)} } // BoolAnd produces (col1 AND col2) as a boolean expression column. func BoolAnd(a, b Col) Col { return Col{expr: "(" + a.expr + " AND " + b.expr + ")"} } // IsTrue converts a boolean Col expression into a Cond for use in WHERE/WHEN clauses. func (c Col) IsTrue() Cond { return Cond{fragment: c.expr} } // CaseCol builds a SQL CASE expression. Usage: // // CaseCol().When(cond, result).Else(fallback).End() func CaseCol() *CaseBuilder { return &CaseBuilder{} } // CaseBuilder constructs a SQL CASE WHEN ... THEN ... ELSE ... END expression. type CaseBuilder struct { whens []struct { cond Cond result Col } elseCol *Col } func (cb *CaseBuilder) When(cond Cond, result Col) *CaseBuilder { cb.whens = append(cb.whens, struct { cond Cond result Col }{cond, result}) return cb } func (cb *CaseBuilder) Else(c Col) *CaseBuilder { cb.elseCol = &c return cb } func (cb *CaseBuilder) End() Col { var b strings.Builder b.WriteString("CASE") var args []any for _, w := range cb.whens { b.WriteString(" WHEN ") b.WriteString(w.cond.fragment) args = append(args, w.cond.args...) b.WriteString(" THEN ") b.WriteString(w.result.expr) } if cb.elseCol != nil { b.WriteString(" ELSE ") b.WriteString(cb.elseCol.expr) } b.WriteString(" END") // CASE args are baked into the expression since Col doesn't carry args. // For parameterized WHEN conditions, use RawCol instead. _ = args return Col{expr: b.String()} } // Literal creates a Col from a literal SQL value (e.g. a quoted string). func Literal(val string) Col { return Col{expr: "'" + val + "'"} } // Concat produces a SQL concatenation of columns using ||. func Concat(cols ...Col) Col { parts := make([]string, len(cols)) for i, c := range cols { parts[i] = c.expr } return Col{expr: "(" + strings.Join(parts, " || ") + ")"} } func Coalesce(c Col, defaultVal string) Col { return Col{expr: "COALESCE(" + c.expr + ", " + defaultVal + ")"} } // CoalesceCols produces COALESCE(col1, col2, ...) from multiple column expressions. func CoalesceCols(cols ...Col) Col { parts := make([]string, len(cols)) for i, c := range cols { parts[i] = c.expr } return Col{expr: "COALESCE(" + strings.Join(parts, ", ") + ")"} } // As sets a column alias for SELECT lists (produces: expr AS "alias"). func (c Col) As(alias string) Col { return Col{expr: c.expr, alias: alias} } // Cast applies a PostgreSQL type cast (produces: expr::typeName). func (c Col) Cast(typeName string) Col { return Col{expr: c.expr + "::" + typeName, alias: c.alias} } // RawCol creates a Col from a raw SQL expression with an alias. // String() returns the expression with AS "alias" (for SELECT lists). // Asc()/Desc() use only the bare expression (for ORDER BY). func RawCol(expr string, alias string) Col { return Col{expr: expr, alias: alias} } // Cond type Cond struct { fragment string args []any } func (c Cond) And(other Cond) Cond { if c.fragment == "" { return other } if other.fragment == "" { return c } args := make([]any, 0, len(c.args)+len(other.args)) args = append(args, c.args...) args = append(args, other.args...) return Cond{ fragment: "(" + c.fragment + " AND " + other.fragment + ")", args: args, } } func (c Cond) Or(other Cond) Cond { if c.fragment == "" { return other } if other.fragment == "" { return c } args := make([]any, 0, len(c.args)+len(other.args)) args = append(args, c.args...) args = append(args, other.args...) return Cond{ fragment: "(" + c.fragment + " OR " + other.fragment + ")", args: args, } } func (c Cond) Not() Cond { return Cond{fragment: "NOT (" + c.fragment + ")", args: c.args} } func True() Cond { return Cond{fragment: "TRUE"} } func False() Cond { return Cond{fragment: "FALSE"} } func RawCond(fragment string, args ...any) Cond { return Cond{fragment: fragment, args: args} } // Exists produces an EXISTS (subquery) condition. func Exists(sub *SelectBuilder) Cond { subSQL, subArgs := sub.toSQL() return Cond{fragment: "EXISTS (" + subSQL + ")", args: subArgs} } // NotExists produces a NOT EXISTS (subquery) condition. func NotExists(sub *SelectBuilder) Cond { subSQL, subArgs := sub.toSQL() return Cond{fragment: "NOT EXISTS (" + subSQL + ")", args: subArgs} } // SubQuery wraps a SelectBuilder as a Col expression so it can be used in // comparisons, COALESCE, etc. Produces "(SELECT ...)". func SubQuery(sub *SelectBuilder) Col { subSQL, subArgs := sub.toSQL() // SubQuery args are embedded into the fragment since Col doesn't carry args // independently. For parameterized subqueries, use Exists/NotExists/InQuery instead. _ = subArgs return Col{expr: "(" + subSQL + ")"} } type OrderExpr struct { expr string } func RawOrder(sql string) OrderExpr { return OrderExpr{expr: sql} } // SelectBuilder type joinClause struct { joinType string table TableRef on Cond } type SelectBuilder struct { columns []string from []TableRef joins []joinClause where *Cond groupBy []string having *Cond orderBy []OrderExpr limit *int64 offset *int64 debug bool } func (b *SelectBuilder) Debug() *SelectBuilder { b.debug = true return b } func Select(cols ...string) *SelectBuilder { return &SelectBuilder{columns: cols} } // SetColumns replaces the SELECT column list on an existing builder. func (b *SelectBuilder) SetColumns(cols ...string) *SelectBuilder { b.columns = cols return b } func (b *SelectBuilder) From(tables ...TableExpr) *SelectBuilder { for _, t := range tables { b.from = append(b.from, t.tableRef()) } return b } func (b *SelectBuilder) InnerJoin(table TableExpr, on Cond) *SelectBuilder { b.joins = append(b.joins, joinClause{joinType: "INNER JOIN", table: table.tableRef(), on: on}) return b } func (b *SelectBuilder) LeftJoin(table TableExpr, on Cond) *SelectBuilder { b.joins = append(b.joins, joinClause{joinType: "LEFT JOIN", table: table.tableRef(), on: on}) return b } func (b *SelectBuilder) Where(cond Cond) *SelectBuilder { b.where = &cond return b } func (b *SelectBuilder) AndWhere(cond Cond) *SelectBuilder { if b.where == nil { b.where = &cond } else { combined := b.where.And(cond) b.where = &combined } return b } func (b *SelectBuilder) GroupBy(cols ...string) *SelectBuilder { b.groupBy = append(b.groupBy, cols...) return b } // Having sets the HAVING clause (filters groups after aggregation). Calling it // again replaces the previous condition; combine multiple predicates with And. func (b *SelectBuilder) Having(cond Cond) *SelectBuilder { b.having = &cond return b } func (b *SelectBuilder) OrderBy(exprs ...OrderExpr) *SelectBuilder { b.orderBy = append(b.orderBy, exprs...) return b } func (b *SelectBuilder) Limit(n int64) *SelectBuilder { b.limit = &n return b } func (b *SelectBuilder) Offset(n int64) *SelectBuilder { b.offset = &n return b } func (b *SelectBuilder) HasOrderBy() bool { return len(b.orderBy) > 0 } func (b *SelectBuilder) Build() (string, []any) { sql, args := b.toSQL() return replaceParams(sql), args } func (b *SelectBuilder) toSQL() (string, []any) { var sb strings.Builder var args []any sb.WriteString("SELECT ") sb.WriteString(strings.Join(b.columns, ", ")) if len(b.from) > 0 { sb.WriteString(" FROM ") parts := make([]string, len(b.from)) for i, t := range b.from { parts[i] = t.fromExpr() } sb.WriteString(strings.Join(parts, ", ")) } for _, j := range b.joins { sb.WriteString(" ") sb.WriteString(j.joinType) sb.WriteString(" ") sb.WriteString(j.table.fromExpr()) sb.WriteString(" ON ") sb.WriteString(j.on.fragment) args = append(args, j.on.args...) } if b.where != nil { sb.WriteString(" WHERE ") sb.WriteString(b.where.fragment) args = append(args, b.where.args...) } if len(b.groupBy) > 0 { sb.WriteString(" GROUP BY ") sb.WriteString(strings.Join(b.groupBy, ", ")) } if b.having != nil { sb.WriteString(" HAVING ") sb.WriteString(b.having.fragment) args = append(args, b.having.args...) } if len(b.orderBy) > 0 { sb.WriteString(" ORDER BY ") parts := make([]string, len(b.orderBy)) for i, o := range b.orderBy { parts[i] = o.expr } sb.WriteString(strings.Join(parts, ", ")) } if b.limit != nil { fmt.Fprintf(&sb, " LIMIT %d", *b.limit) } if b.offset != nil { fmt.Fprintf(&sb, " OFFSET %d", *b.offset) } return sb.String(), args } func (b *SelectBuilder) Query(ctx context.Context, db Querier, dest any) error { sql, args := b.Build() if b.debug { debugQuery(sql, args) } return QueryAll(ctx, db, dest, sql, args...) } func (b *SelectBuilder) QueryRow(ctx context.Context, db Querier, dest any) error { sql, args := b.Build() if b.debug { debugQuery(sql, args) } return QueryOne(ctx, db, dest, sql, args...) } func (b *SelectBuilder) QueryScalarTo(ctx context.Context, db Querier, dest any) error { query, args := b.Build() if b.debug { debugQuery(query, args) } rows, err := db.QueryContext(ctx, query, args...) if err != nil { return err } defer rows.Close() if !rows.Next() { if err := rows.Err(); err != nil { return err } return sql.ErrNoRows } return rows.Scan(dest) } // InsertBuilder type InsertBuilder struct { table TableRef columns []string values []any model any returning []string debug bool } func (b *InsertBuilder) Debug() *InsertBuilder { b.debug = true return b } func InsertInto(table TableExpr) *InsertBuilder { return &InsertBuilder{table: table.tableRef()} } func (b *InsertBuilder) Columns(cols ...string) *InsertBuilder { b.columns = cols return b } func (b *InsertBuilder) Values(vals ...any) *InsertBuilder { b.values = vals return b } func (b *InsertBuilder) Model(model any) *InsertBuilder { b.model = model return b } func (b *InsertBuilder) Returning(cols ...string) *InsertBuilder { b.returning = cols return b } func (b *InsertBuilder) Build() (string, []any) { columns := b.columns var args []any if b.model != nil { if len(columns) == 0 { t := reflect.TypeOf(b.model) if t.Kind() == reflect.Ptr { t = t.Elem() } columns = allDBColumns(t) } args = extractModelValues(b.model, columns) } else { args = b.values } placeholders := make([]string, len(columns)) for i := range columns { placeholders[i] = "?" } sql := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", b.table.tableName, strings.Join(columns, ", "), strings.Join(placeholders, ", ")) if len(b.returning) > 0 { sql += " RETURNING " + strings.Join(b.returning, ", ") } return replaceParams(sql), args } func (b *InsertBuilder) Exec(ctx context.Context, db Execer) (sql.Result, error) { sql, args := b.Build() if b.debug { debugQuery(sql, args) } return db.ExecContext(ctx, sql, args...) } func (b *InsertBuilder) QueryRow(ctx context.Context, db Querier, dest any) error { sql, args := b.Build() if b.debug { debugQuery(sql, args) } return QueryOne(ctx, db, dest, sql, args...) } // UpdateBuilder type setClause struct { col string val any } type UpdateBuilder struct { table TableRef sets []setClause setCols []string model any where *Cond debug bool } func (b *UpdateBuilder) Debug() *UpdateBuilder { b.debug = true return b } func Update(table TableExpr) *UpdateBuilder { return &UpdateBuilder{table: table.tableRef()} } func (b *UpdateBuilder) Set(col any, val any) *UpdateBuilder { var name string switch c := col.(type) { case Col: name = c.expr case string: name = c default: panic(fmt.Sprintf("builder: Set col must be Col or string, got %T", col)) } b.sets = append(b.sets, setClause{col: name, val: val}) return b } func (b *UpdateBuilder) SetColumns(cols ...string) *UpdateBuilder { b.setCols = cols return b } func (b *UpdateBuilder) Model(model any) *UpdateBuilder { b.model = model return b } func (b *UpdateBuilder) Where(cond Cond) *UpdateBuilder { b.where = &cond return b } func (b *UpdateBuilder) Build() (string, []any) { var setClauses []string var args []any if b.model != nil { cols := b.setCols if len(cols) == 0 { t := reflect.TypeOf(b.model) if t.Kind() == reflect.Ptr { t = t.Elem() } cols = allDBColumns(t) } vals := extractModelValues(b.model, cols) for i, col := range cols { setClauses = append(setClauses, col+" = ?") args = append(args, vals[i]) } } for _, s := range b.sets { setClauses = append(setClauses, s.col+" = ?") args = append(args, s.val) } sql := "UPDATE " + b.table.tableName + " SET " + strings.Join(setClauses, ", ") if b.where != nil { sql += " WHERE " + b.where.fragment args = append(args, b.where.args...) } return replaceParams(sql), args } func (b *UpdateBuilder) Exec(ctx context.Context, db Execer) (sql.Result, error) { sql, args := b.Build() if b.debug { debugQuery(sql, args) } return db.ExecContext(ctx, sql, args...) } // DeleteBuilder type DeleteBuilder struct { table TableRef where *Cond debug bool } func (b *DeleteBuilder) Debug() *DeleteBuilder { b.debug = true return b } func DeleteFrom(table TableExpr) *DeleteBuilder { return &DeleteBuilder{table: table.tableRef()} } func (b *DeleteBuilder) Where(cond Cond) *DeleteBuilder { b.where = &cond return b } func (b *DeleteBuilder) Build() (string, []any) { sql := "DELETE FROM " + b.table.tableName var args []any if b.where != nil { sql += " WHERE " + b.where.fragment args = append(args, b.where.args...) } return replaceParams(sql), args } func (b *DeleteBuilder) Exec(ctx context.Context, db Execer) (sql.Result, error) { sql, args := b.Build() if b.debug { debugQuery(sql, args) } return db.ExecContext(ctx, sql, args...) } // debugQuery prints the SQL and args to stdout when debug mode is enabled. func debugQuery(sql string, args []any) { fmt.Println("\n[dbutil:debug] SQL:", sql) if len(args) > 0 { fmt.Print("[dbutil:debug] Args: [") for i, arg := range args { if i > 0 { fmt.Print(", ") } fmt.Printf("%v", arg) } fmt.Println("]") } fmt.Println() } // Helpers func replaceParams(sql string) string { var b strings.Builder n := 1 for i := range len(sql) { if sql[i] == '?' { fmt.Fprintf(&b, "$%d", n) n++ } else { b.WriteByte(sql[i]) } } return b.String() } func extractModelValues(model any, columns []string) []any { v := reflect.ValueOf(model) if v.Kind() == reflect.Ptr { v = v.Elem() } t := v.Type() tagMap := make(map[string]int, t.NumField()) for i := range t.NumField() { if tag := t.Field(i).Tag.Get("db"); tag != "" && tag != "-" { tagMap[tag] = i } } vals := make([]any, len(columns)) for i, col := range columns { if idx, ok := tagMap[col]; ok { vals[i] = v.Field(idx).Interface() } } return vals } func allDBColumns(t reflect.Type) []string { var cols []string for i := range t.NumField() { f := t.Field(i) if !f.IsExported() { continue } if dbTag := f.Tag.Get("db"); dbTag != "" && dbTag != "-" { cols = append(cols, dbTag) } if f.Anonymous { ft := derefType(f.Type) if ft.Kind() == reflect.Struct { cols = append(cols, allDBColumns(ft)...) } } } return cols } func collectColumnsFlat(t reflect.Type, tableRef string) []string { var cols []string for i := range t.NumField() { f := t.Field(i) if !f.IsExported() { continue } if dbTag := f.Tag.Get("db"); dbTag != "" && dbTag != "-" { if tableRef != "" { cols = append(cols, tableRef+"."+dbTag) } else { cols = append(cols, dbTag) } continue } if f.Anonymous { ft := derefType(f.Type) if ft.Kind() == reflect.Struct { cols = append(cols, collectColumnsFlat(ft, tableRef)...) } } } return cols }