318 lines
9.3 KiB
Go
318 lines
9.3 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ── SQL Dialect Adapter ─────────────────────
|
|
//
|
|
// Q adapts Postgres-flavoured SQL to the current dialect.
|
|
// On Postgres it is a no-op. On SQLite it rewrites:
|
|
// - $1, $2 … $20 → ?
|
|
// - ::jsonb, ::text → removed
|
|
// - NOW() → datetime('now')
|
|
// - true / false → 1 / 0 (bare keywords only, not inside strings)
|
|
// - NULLS LAST → removed (SQLite default)
|
|
// - COALESCE(x::text, …) → COALESCE(x, …)
|
|
//
|
|
// Call it at every database.DB.Query / Exec / QueryRow site in handlers.
|
|
func Q(query string) string {
|
|
if !IsSQLite() {
|
|
return query
|
|
}
|
|
q := query
|
|
|
|
// Replace $N placeholders high-to-low to avoid $1 matching inside $10.
|
|
for i := 20; i >= 1; i-- {
|
|
q = strings.ReplaceAll(q, fmt.Sprintf("$%d", i), "?")
|
|
}
|
|
|
|
// Postgres type casts
|
|
q = strings.ReplaceAll(q, "::jsonb", "")
|
|
q = strings.ReplaceAll(q, "::text", "")
|
|
|
|
// Time functions
|
|
q = strings.ReplaceAll(q, "NOW()", "datetime('now')")
|
|
|
|
// Boolean literals (bare keywords, not inside quotes)
|
|
q = strings.ReplaceAll(q, "= true", "= 1")
|
|
q = strings.ReplaceAll(q, "= false", "= 0")
|
|
q = strings.ReplaceAll(q, "COALESCE(is_private, false)", "COALESCE(is_private, 0)")
|
|
|
|
// NULL sort order (SQLite puts NULLs last by default for DESC)
|
|
q = strings.ReplaceAll(q, "NULLS LAST", "")
|
|
|
|
// Case-insensitive LIKE (SQLite LIKE is already case-insensitive for ASCII)
|
|
q = strings.ReplaceAll(q, "ILIKE", "LIKE")
|
|
|
|
return q
|
|
}
|
|
|
|
// QArgs adapts a Postgres query AND its args for the current dialect.
|
|
//
|
|
// Unlike Q() which only rewrites the query string, QArgs also expands the
|
|
// args slice to match positional ? placeholders. In Postgres, $1 can appear
|
|
// multiple times and references the same arg. In SQLite, each ? is
|
|
// positional — if $1 appears twice, the arg must appear twice.
|
|
//
|
|
// Use QArgs for any handler query that references $N more than once
|
|
// (typically subqueries like "WHERE user_id = $1 OR ... participant_id = $1").
|
|
//
|
|
// On Postgres, returns query and args unchanged.
|
|
func QArgs(query string, args ...interface{}) (string, []interface{}) {
|
|
if !IsSQLite() {
|
|
return query, args
|
|
}
|
|
|
|
// First, apply the non-placeholder rewrites
|
|
q := query
|
|
q = strings.ReplaceAll(q, "::jsonb", "")
|
|
q = strings.ReplaceAll(q, "::text", "")
|
|
q = strings.ReplaceAll(q, "NOW()", "datetime('now')")
|
|
q = strings.ReplaceAll(q, "= true", "= 1")
|
|
q = strings.ReplaceAll(q, "= false", "= 0")
|
|
q = strings.ReplaceAll(q, "COALESCE(is_private, false)", "COALESCE(is_private, 0)")
|
|
q = strings.ReplaceAll(q, "NULLS LAST", "")
|
|
q = strings.ReplaceAll(q, "ILIKE", "LIKE")
|
|
|
|
// Walk the query left-to-right, find each $N, record which arg index
|
|
// it maps to, replace with ?, and build a new args list in order.
|
|
var newArgs []interface{}
|
|
var result strings.Builder
|
|
i := 0
|
|
for i < len(q) {
|
|
if q[i] == '$' && i+1 < len(q) && q[i+1] >= '1' && q[i+1] <= '9' {
|
|
// Parse the number after $
|
|
j := i + 1
|
|
for j < len(q) && q[j] >= '0' && q[j] <= '9' {
|
|
j++
|
|
}
|
|
numStr := q[i+1 : j]
|
|
n := 0
|
|
for _, c := range numStr {
|
|
n = n*10 + int(c-'0')
|
|
}
|
|
// $N is 1-based, args is 0-based
|
|
if n >= 1 && n <= len(args) {
|
|
newArgs = append(newArgs, args[n-1])
|
|
}
|
|
result.WriteByte('?')
|
|
i = j
|
|
} else {
|
|
result.WriteByte(q[i])
|
|
i++
|
|
}
|
|
}
|
|
|
|
return result.String(), newArgs
|
|
}
|
|
|
|
// ── Safe Query Wrappers ─────────────────────
|
|
//
|
|
// These wrappers replace the pattern:
|
|
// database.DB.QueryRow(database.Q(sql), args...)
|
|
// with:
|
|
// database.QueryRow(sql, args...)
|
|
//
|
|
// They call QArgs internally, handling $N→? conversion AND arg expansion
|
|
// for repeated placeholder references. Use these for ALL handler-level
|
|
// SQL that uses $N placeholders.
|
|
|
|
// QueryRow executes a query with dialect adaptation and arg expansion.
|
|
func QueryRow(query string, args ...interface{}) *sql.Row {
|
|
q, expanded := QArgs(query, args...)
|
|
return DB.QueryRow(q, expanded...)
|
|
}
|
|
|
|
// QueryRowContext is the context-aware variant of QueryRow.
|
|
func QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row {
|
|
q, expanded := QArgs(query, args...)
|
|
return DB.QueryRowContext(ctx, q, expanded...)
|
|
}
|
|
|
|
// Query executes a query returning rows, with dialect adaptation.
|
|
func Query(query string, args ...interface{}) (*sql.Rows, error) {
|
|
q, expanded := QArgs(query, args...)
|
|
return DB.Query(q, expanded...)
|
|
}
|
|
|
|
// QueryContext is the context-aware variant of Query.
|
|
func QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
|
|
q, expanded := QArgs(query, args...)
|
|
return DB.QueryContext(ctx, q, expanded...)
|
|
}
|
|
|
|
// Exec executes a statement with dialect adaptation.
|
|
func Exec(query string, args ...interface{}) (sql.Result, error) {
|
|
q, expanded := QArgs(query, args...)
|
|
return DB.Exec(q, expanded...)
|
|
}
|
|
|
|
// ExecContext is the context-aware variant of Exec.
|
|
func ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
|
|
q, expanded := QArgs(query, args...)
|
|
return DB.ExecContext(ctx, q, expanded...)
|
|
}
|
|
|
|
// InsertReturningID executes an INSERT … RETURNING id query.
|
|
// On Postgres it uses RETURNING directly.
|
|
// On SQLite it strips RETURNING, generates a UUID, prepends it to the args,
|
|
// adds an "id" column to the INSERT, and executes.
|
|
func InsertReturningID(query string, args ...interface{}) (string, error) {
|
|
if !IsSQLite() {
|
|
var id string
|
|
err := DB.QueryRow(query, args...).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
// Generate ID for SQLite
|
|
id := uuid.New().String()
|
|
|
|
// Adapt SQL: strip RETURNING, convert placeholders, add id column+value
|
|
q := Q(query)
|
|
|
|
// Remove RETURNING clause
|
|
if idx := strings.Index(strings.ToUpper(q), "RETURNING"); idx >= 0 {
|
|
q = strings.TrimSpace(q[:idx])
|
|
}
|
|
|
|
// Inject id column and value into INSERT
|
|
// Pattern: INSERT INTO table (col1, col2, ...) VALUES (?, ?, ...)
|
|
q = injectIDColumn(q)
|
|
|
|
// Prepend id to args
|
|
newArgs := make([]interface{}, 0, len(args)+1)
|
|
newArgs = append(newArgs, id)
|
|
newArgs = append(newArgs, args...)
|
|
|
|
_, err := DB.Exec(q, newArgs...)
|
|
return id, err
|
|
}
|
|
|
|
// InjectIDForTest adds "id" as the first column and "?" as the first value
|
|
// in an INSERT statement. Exported for use in test helpers.
|
|
func InjectIDForTest(q string) string {
|
|
return injectIDColumn(q)
|
|
}
|
|
|
|
// injectIDColumn adds "id" as the first column and "?" as the first value
|
|
// in an INSERT statement.
|
|
func injectIDColumn(q string) string {
|
|
// Find the opening paren of the column list
|
|
upper := strings.ToUpper(q)
|
|
insertIdx := strings.Index(upper, "INSERT INTO")
|
|
if insertIdx < 0 {
|
|
return q
|
|
}
|
|
|
|
// Find first ( after INSERT INTO tablename
|
|
firstParen := strings.Index(q[insertIdx:], "(")
|
|
if firstParen < 0 {
|
|
return q
|
|
}
|
|
firstParen += insertIdx
|
|
|
|
// Find VALUES (
|
|
valuesIdx := strings.Index(upper, "VALUES")
|
|
if valuesIdx < 0 {
|
|
return q
|
|
}
|
|
valuesParen := strings.Index(q[valuesIdx:], "(")
|
|
if valuesParen < 0 {
|
|
return q
|
|
}
|
|
valuesParen += valuesIdx
|
|
|
|
// Insert "id, " after first ( and "?, " after VALUES (
|
|
result := q[:firstParen+1] + "id, " + q[firstParen+1:valuesParen+1] + "?, " + q[valuesParen+1:]
|
|
return result
|
|
}
|
|
|
|
// ── Time Scan Helpers ───────────────────────
|
|
//
|
|
// SQLite returns timestamps as TEXT strings. These helpers wrap *time.Time
|
|
// and sql.NullTime destinations so Scan() works on both dialects.
|
|
|
|
// ScanTime wraps a *time.Time for use in handler-level Scan() calls.
|
|
// On Postgres, time.Time scans natively. On SQLite, parses from string.
|
|
type ScanTime struct{ T *time.Time }
|
|
|
|
func (s *ScanTime) Scan(src interface{}) error {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
switch v := src.(type) {
|
|
case time.Time:
|
|
*s.T = v
|
|
return nil
|
|
case string:
|
|
for _, f := range compatTimeFormats {
|
|
if p, err := time.Parse(f, v); err == nil {
|
|
*s.T = p
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("ScanTime: cannot parse %q", v)
|
|
default:
|
|
return fmt.Errorf("ScanTime: unsupported type %T", src)
|
|
}
|
|
}
|
|
|
|
// ST creates a ScanTime wrapper for use in Scan() argument lists.
|
|
func ST(t *time.Time) *ScanTime { return &ScanTime{T: t} }
|
|
|
|
// ScanNullTime wraps a nullable time destination.
|
|
type ScanNullTime struct{ T **time.Time }
|
|
|
|
func (s *ScanNullTime) 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
|
|
}
|
|
|
|
// SNT creates a ScanNullTime wrapper for use in Scan() argument lists.
|
|
func SNT(t **time.Time) *ScanNullTime { return &ScanNullTime{T: t} }
|
|
|
|
// NullTimeValue extracts a time.Time from sql.NullTime or *time.Time,
|
|
// returning the zero value if nil/invalid. Useful in JSON serialization.
|
|
func NullTimeValue(t *time.Time) time.Time {
|
|
if t == nil {
|
|
return time.Time{}
|
|
}
|
|
return *t
|
|
}
|
|
|
|
var compatTimeFormats = []string{
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02T15:04:05Z",
|
|
time.RFC3339,
|
|
"2006-01-02 15:04:05.000000000+00:00",
|
|
}
|
|
|
|
// ── Unique Constraint Detection ─────────────
|
|
|
|
// IsUniqueViolation checks if an error is a unique constraint violation,
|
|
// working on both Postgres and SQLite.
|
|
func IsUniqueViolation(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := err.Error()
|
|
return strings.Contains(msg, "duplicate key") || // Postgres
|
|
strings.Contains(msg, "UNIQUE constraint failed") // SQLite
|
|
}
|