All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m14s
CI/CD / test-sqlite (pull_request) Successful in 2m35s
CI/CD / build-and-deploy (pull_request) Successful in 27s
Team admin settings audit (pass 1): remove dead BYOK/provider/persona vestiges from team store, models, policy defaults, workflow stage UI, and ICD tests. Fix stale chat_only stage mode in frontend to match backend CHECK constraint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
259 lines
6.1 KiB
Go
259 lines
6.1 KiB
Go
package postgres
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
// DB is the shared database connection pool.
|
|
// Set during initialization via SetDB.
|
|
var DB *sql.DB
|
|
|
|
// SetDB configures the shared database connection for all stores.
|
|
func SetDB(db *sql.DB) {
|
|
DB = db
|
|
}
|
|
|
|
// ── Dynamic SQL Builder ─────────────────────
|
|
// Replaces the copy-pasted addClause/addField pattern.
|
|
|
|
// UpdateBuilder constructs a dynamic UPDATE statement.
|
|
type UpdateBuilder struct {
|
|
table string
|
|
sets []string
|
|
args []interface{}
|
|
where []string
|
|
argIdx int
|
|
}
|
|
|
|
// NewUpdate creates an UpdateBuilder for the given table.
|
|
func NewUpdate(table string) *UpdateBuilder {
|
|
return &UpdateBuilder{table: table}
|
|
}
|
|
|
|
// Set adds a column=value pair to the UPDATE.
|
|
func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder {
|
|
b.argIdx++
|
|
b.sets = append(b.sets, fmt.Sprintf("%s = $%d", col, b.argIdx))
|
|
b.args = append(b.args, val)
|
|
return b
|
|
}
|
|
|
|
// SetNull adds a col = NULL pair to the UPDATE.
|
|
func (b *UpdateBuilder) SetNull(col string) *UpdateBuilder {
|
|
b.sets = append(b.sets, fmt.Sprintf("%s = NULL", col))
|
|
return b
|
|
}
|
|
|
|
// SetJSON adds a JSONB column from a map.
|
|
func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder {
|
|
data, err := json.Marshal(val)
|
|
if err != nil {
|
|
data = []byte("{}")
|
|
}
|
|
return b.Set(col, string(data))
|
|
}
|
|
|
|
// SetIf conditionally adds a column if the pointer is non-nil.
|
|
func (b *UpdateBuilder) SetIf(col string, val interface{}, set bool) *UpdateBuilder {
|
|
if set {
|
|
return b.Set(col, val)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Where adds a WHERE condition.
|
|
func (b *UpdateBuilder) Where(col string, val interface{}) *UpdateBuilder {
|
|
b.argIdx++
|
|
b.where = append(b.where, fmt.Sprintf("%s = $%d", col, b.argIdx))
|
|
b.args = append(b.args, val)
|
|
return b
|
|
}
|
|
|
|
// HasSets returns true if any SET clauses were added.
|
|
func (b *UpdateBuilder) HasSets() bool {
|
|
return len(b.sets) > 0
|
|
}
|
|
|
|
// Build returns the SQL string and args.
|
|
func (b *UpdateBuilder) Build() (string, []interface{}) {
|
|
sql := fmt.Sprintf("UPDATE %s SET %s", b.table, strings.Join(b.sets, ", "))
|
|
if len(b.where) > 0 {
|
|
sql += " WHERE " + strings.Join(b.where, " AND ")
|
|
}
|
|
return sql, b.args
|
|
}
|
|
|
|
// Exec executes the built UPDATE.
|
|
func (b *UpdateBuilder) Exec(db *sql.DB) (sql.Result, error) {
|
|
q, args := b.Build()
|
|
return db.Exec(q, args...)
|
|
}
|
|
|
|
// ── Query Builder ───────────────────────────
|
|
|
|
// SelectBuilder constructs a dynamic SELECT statement.
|
|
type SelectBuilder struct {
|
|
cols string
|
|
table string
|
|
joins []string
|
|
where []string
|
|
args []interface{}
|
|
orderBy string
|
|
limit int
|
|
offset int
|
|
argIdx int
|
|
}
|
|
|
|
// NewSelect creates a SelectBuilder.
|
|
func NewSelect(cols, table string) *SelectBuilder {
|
|
return &SelectBuilder{cols: cols, table: table}
|
|
}
|
|
|
|
// Join adds a JOIN clause.
|
|
func (b *SelectBuilder) Join(join string) *SelectBuilder {
|
|
b.joins = append(b.joins, join)
|
|
return b
|
|
}
|
|
|
|
// Where adds a WHERE condition with a parameter.
|
|
func (b *SelectBuilder) Where(clause string, args ...interface{}) *SelectBuilder {
|
|
for _, arg := range args {
|
|
b.argIdx++
|
|
clause = strings.Replace(clause, "?", fmt.Sprintf("$%d", b.argIdx), 1)
|
|
b.args = append(b.args, arg)
|
|
}
|
|
b.where = append(b.where, clause)
|
|
return b
|
|
}
|
|
|
|
// WhereRaw adds a WHERE condition without parameters.
|
|
func (b *SelectBuilder) WhereRaw(clause string) *SelectBuilder {
|
|
b.where = append(b.where, clause)
|
|
return b
|
|
}
|
|
|
|
// OrderBy sets the ORDER BY clause.
|
|
func (b *SelectBuilder) OrderBy(col, order string) *SelectBuilder {
|
|
if order == "" {
|
|
order = "DESC"
|
|
}
|
|
b.orderBy = fmt.Sprintf("%s %s", col, strings.ToUpper(order))
|
|
return b
|
|
}
|
|
|
|
// Paginate sets LIMIT and OFFSET from ListOptions.
|
|
func (b *SelectBuilder) Paginate(opts store.ListOptions) *SelectBuilder {
|
|
if opts.Limit > 0 {
|
|
b.limit = opts.Limit
|
|
}
|
|
if opts.Offset > 0 {
|
|
b.offset = opts.Offset
|
|
}
|
|
if opts.Sort != "" {
|
|
b.OrderBy(opts.Sort, opts.Order)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Build returns the SQL string and args.
|
|
func (b *SelectBuilder) Build() (string, []interface{}) {
|
|
q := fmt.Sprintf("SELECT %s FROM %s", b.cols, b.table)
|
|
for _, j := range b.joins {
|
|
q += " " + j
|
|
}
|
|
if len(b.where) > 0 {
|
|
q += " WHERE " + strings.Join(b.where, " AND ")
|
|
}
|
|
if b.orderBy != "" {
|
|
q += " ORDER BY " + b.orderBy
|
|
}
|
|
if b.limit > 0 {
|
|
q += fmt.Sprintf(" LIMIT %d", b.limit)
|
|
}
|
|
if b.offset > 0 {
|
|
q += fmt.Sprintf(" OFFSET %d", b.offset)
|
|
}
|
|
return q, b.args
|
|
}
|
|
|
|
// CountBuild returns a SELECT COUNT(*) version of the query (no order/limit).
|
|
func (b *SelectBuilder) CountBuild() (string, []interface{}) {
|
|
q := fmt.Sprintf("SELECT COUNT(*) FROM %s", b.table)
|
|
for _, j := range b.joins {
|
|
q += " " + j
|
|
}
|
|
if len(b.where) > 0 {
|
|
q += " WHERE " + strings.Join(b.where, " AND ")
|
|
}
|
|
return q, b.args
|
|
}
|
|
|
|
// ── JSONB Helpers ───────────────────────────
|
|
|
|
// ToJSON marshals a value to JSON bytes for JSONB columns.
|
|
func ToJSON(v interface{}) []byte {
|
|
if v == nil {
|
|
return []byte("{}")
|
|
}
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return []byte("{}")
|
|
}
|
|
return b
|
|
}
|
|
|
|
// ScanJSON scans a JSONB column into a target.
|
|
func ScanJSON(src interface{}, dst interface{}) error {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
var data []byte
|
|
switch v := src.(type) {
|
|
case []byte:
|
|
data = v
|
|
case string:
|
|
data = []byte(v)
|
|
default:
|
|
return fmt.Errorf("unsupported JSONB type: %T", src)
|
|
}
|
|
return json.Unmarshal(data, dst)
|
|
}
|
|
|
|
// NullableString returns the string value or empty string from sql.NullString.
|
|
func NullableString(ns sql.NullString) string {
|
|
if ns.Valid {
|
|
return ns.String
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// NullableStringPtr returns a *string from sql.NullString (nil if not valid).
|
|
func NullableStringPtr(ns sql.NullString) *string {
|
|
if ns.Valid {
|
|
return &ns.String
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NullableFloat64Ptr returns a *float64 from sql.NullFloat64.
|
|
func NullableFloat64Ptr(nf sql.NullFloat64) *float64 {
|
|
if nf.Valid {
|
|
return &nf.Float64
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NullableIntPtr returns an *int from sql.NullInt64.
|
|
func NullableIntPtr(ni sql.NullInt64) *int {
|
|
if ni.Valid {
|
|
v := int(ni.Int64)
|
|
return &v
|
|
}
|
|
return nil
|
|
}
|