- Rename Go module switchboard-core → armature (155+ files) - Rename Docker image → gobha/armature - Rename K8s resources, secrets, deployments - Rename Prometheus metrics switchboard_* → armature_* - Rename env vars SWITCHBOARD_ADMIN_* → ARMATURE_ADMIN_* - Rename DB names switchboard_core* → armature* - Update all frontend branding, notification templates, docs - Update CI scripts, e2e tests, Keycloak realm, nginx conf - Rename scripts/switchboard-ca.sh → scripts/armature-ca.sh - Rename k8s/switchboard.yaml → k8s/armature.yaml - Rename chart alerting/dashboard files - Fix: DockerHub push uses env: binding for secret injection - Helm chart updated (name, labels, template functions, dashboard, alerting) - Replace favicon/icon assets with Armature brand No functional changes. Pure mechanical rename + CI fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
342 lines
7.7 KiB
Go
342 lines
7.7 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"armature/store"
|
|
)
|
|
|
|
// DB is the shared database connection pool.
|
|
var DB *sql.DB
|
|
|
|
// timeFmt is the SQLite-compatible time format for explicit timestamps.
|
|
// Must match datetime('now') output used in schema DEFAULT expressions.
|
|
const timeFmt = "2006-01-02 15:04:05"
|
|
|
|
// ── Time Scanner ────────────────────────────
|
|
// modernc/sqlite returns TEXT columns as raw strings, not time.Time.
|
|
// st() wraps a *time.Time destination so Scan auto-parses the string.
|
|
|
|
type sqliteTime struct{ t *time.Time }
|
|
|
|
func st(t *time.Time) *sqliteTime { return &sqliteTime{t: t} }
|
|
|
|
func (s *sqliteTime) Scan(src interface{}) error {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
switch v := src.(type) {
|
|
case time.Time:
|
|
*s.t = v
|
|
return nil
|
|
case int64:
|
|
// modernc/sqlite stores Go time.Time as Unix timestamp (int64).
|
|
// This happens when code passes *time.Time directly as a ?
|
|
// parameter instead of using datetime('now') in SQL.
|
|
*s.t = time.Unix(v, 0).UTC()
|
|
return nil
|
|
case string:
|
|
for _, f := range timeFormats {
|
|
if p, err := time.Parse(f, v); err == nil {
|
|
*s.t = p
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("sqliteTime: cannot parse %q", v)
|
|
default:
|
|
return fmt.Errorf("sqliteTime: unsupported type %T", src)
|
|
}
|
|
}
|
|
|
|
var timeFormats = []string{
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02T15:04:05Z",
|
|
time.RFC3339,
|
|
"2006-01-02 15:04:05.000000000+00:00",
|
|
"2006-01-02 15:04:05Z07:00", // modernc: time.Time UTC → "2025-03-11 16:00:00Z"
|
|
"2006-01-02 15:04:05.999999999Z07:00", // modernc: time.Time with fractional seconds
|
|
}
|
|
|
|
// stN wraps a *time.Time pointer for nullable columns.
|
|
type sqliteTimeNullable struct{ t **time.Time }
|
|
|
|
func stN(t **time.Time) *sqliteTimeNullable { return &sqliteTimeNullable{t: t} }
|
|
|
|
func (s *sqliteTimeNullable) Scan(src interface{}) error {
|
|
if src == nil {
|
|
*s.t = nil
|
|
return nil
|
|
}
|
|
var parsed time.Time
|
|
scanner := st(&parsed)
|
|
if err := scanner.Scan(src); err != nil {
|
|
return err
|
|
}
|
|
*s.t = &parsed
|
|
return nil
|
|
}
|
|
|
|
// SetDB configures the shared database connection for all stores.
|
|
func SetDB(db *sql.DB) {
|
|
DB = db
|
|
}
|
|
|
|
// ── Dynamic SQL Builder (? placeholders) ────
|
|
|
|
// UpdateBuilder constructs a dynamic UPDATE with ? placeholders.
|
|
type UpdateBuilder struct {
|
|
table string
|
|
sets []string
|
|
args []interface{}
|
|
where []string
|
|
}
|
|
|
|
func NewUpdate(table string) *UpdateBuilder {
|
|
return &UpdateBuilder{table: table}
|
|
}
|
|
|
|
func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder {
|
|
b.sets = append(b.sets, col+" = ?")
|
|
b.args = append(b.args, val)
|
|
return b
|
|
}
|
|
|
|
func (b *UpdateBuilder) SetNull(col string) *UpdateBuilder {
|
|
b.sets = append(b.sets, col+" = NULL")
|
|
return b
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
func (b *UpdateBuilder) SetIf(col string, val interface{}, set bool) *UpdateBuilder {
|
|
if set {
|
|
return b.Set(col, val)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// SetExpr sets a column to a raw SQL expression (not parameterized).
|
|
func (b *UpdateBuilder) SetExpr(col, expr string) *UpdateBuilder {
|
|
b.sets = append(b.sets, col+" = "+expr)
|
|
return b
|
|
}
|
|
|
|
// nowExpr returns the SQL expression for "current timestamp" in ISO 8601 format.
|
|
const nowExpr = "datetime('now')"
|
|
|
|
func (b *UpdateBuilder) Where(col string, val interface{}) *UpdateBuilder {
|
|
b.where = append(b.where, col+" = ?")
|
|
b.args = append(b.args, val)
|
|
return b
|
|
}
|
|
|
|
func (b *UpdateBuilder) HasSets() bool {
|
|
return len(b.sets) > 0
|
|
}
|
|
|
|
func (b *UpdateBuilder) Build() (string, []interface{}) {
|
|
q := fmt.Sprintf("UPDATE %s SET %s", b.table, strings.Join(b.sets, ", "))
|
|
if len(b.where) > 0 {
|
|
q += " WHERE " + strings.Join(b.where, " AND ")
|
|
}
|
|
return q, b.args
|
|
}
|
|
|
|
func (b *UpdateBuilder) Exec(db *sql.DB) (sql.Result, error) {
|
|
q, args := b.Build()
|
|
return db.Exec(q, args...)
|
|
}
|
|
|
|
// ── Select Builder ──────────────────────────
|
|
|
|
type SelectBuilder struct {
|
|
cols string
|
|
table string
|
|
joins []string
|
|
where []string
|
|
args []interface{}
|
|
orderBy string
|
|
limit int
|
|
offset int
|
|
}
|
|
|
|
func NewSelect(cols, table string) *SelectBuilder {
|
|
return &SelectBuilder{cols: cols, table: table}
|
|
}
|
|
|
|
func (b *SelectBuilder) Join(join string) *SelectBuilder {
|
|
b.joins = append(b.joins, join)
|
|
return b
|
|
}
|
|
|
|
func (b *SelectBuilder) Where(clause string, args ...interface{}) *SelectBuilder {
|
|
b.where = append(b.where, clause)
|
|
b.args = append(b.args, args...)
|
|
return b
|
|
}
|
|
|
|
func (b *SelectBuilder) WhereRaw(clause string) *SelectBuilder {
|
|
b.where = append(b.where, clause)
|
|
return b
|
|
}
|
|
|
|
func (b *SelectBuilder) OrderBy(col, order string) *SelectBuilder {
|
|
if order == "" {
|
|
order = "DESC"
|
|
}
|
|
b.orderBy = fmt.Sprintf("%s %s", col, strings.ToUpper(order))
|
|
return b
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ── JSON Helpers ────────────────────────────
|
|
|
|
// ToJSON marshals a value to JSON string for TEXT columns.
|
|
func ToJSON(v interface{}) string {
|
|
if v == nil {
|
|
return "{}"
|
|
}
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// ScanJSON scans a TEXT column (JSON) 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 JSON type: %T", src)
|
|
}
|
|
return json.Unmarshal(data, dst)
|
|
}
|
|
|
|
// ArrayToJSON converts a []string to JSON text for storage.
|
|
func ArrayToJSON(arr []string) string {
|
|
if arr == nil {
|
|
return "[]"
|
|
}
|
|
b, _ := json.Marshal(arr)
|
|
return string(b)
|
|
}
|
|
|
|
// ScanArray scans a JSON text array column into []string.
|
|
func ScanArray(src interface{}) []string {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
var data string
|
|
switch v := src.(type) {
|
|
case string:
|
|
data = v
|
|
case []byte:
|
|
data = string(v)
|
|
default:
|
|
return nil
|
|
}
|
|
var arr []string
|
|
if err := json.Unmarshal([]byte(data), &arr); err != nil {
|
|
return nil
|
|
}
|
|
return arr
|
|
}
|
|
|
|
// ── Nullable Helpers ────────────────────────
|
|
|
|
func NullableString(ns sql.NullString) string {
|
|
if ns.Valid {
|
|
return ns.String
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func NullableStringPtr(ns sql.NullString) *string {
|
|
if ns.Valid {
|
|
return &ns.String
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func NullableFloat64Ptr(nf sql.NullFloat64) *float64 {
|
|
if nf.Valid {
|
|
return &nf.Float64
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func NullableIntPtr(ni sql.NullInt64) *int {
|
|
if ni.Valid {
|
|
v := int(ni.Int64)
|
|
return &v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── UUID Generation ─────────────────────────
|
|
|
|
// NewID generates a new UUID string. Imported from the uuid package
|
|
// at the store level to keep the helper package dependency-light.
|
|
// Callers should use store.NewID() instead.
|