package sqlite import ( "database/sql" "encoding/json" "fmt" "strings" "time" "git.gobha.me/xcaliber/chat-switchboard/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.