Changeset 0.17.1 (#76)

This commit is contained in:
2026-02-28 01:40:31 +00:00
parent c9141a6896
commit 856dc9b0ac
64 changed files with 8037 additions and 1657 deletions

209
server/database/compat.go Normal file
View File

@@ -0,0 +1,209 @@
package database
import (
"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
}
// 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
}