Files
wangjia 86b67c557b feat(server/db): 多库(3/4)— Dialect 抽象(upsert/锁)+ 合并 directory_version
- db.Dialect:LockForUpdate(mysql "FOR UPDATE" / sqlite "")、Upsert(中性
  EXCLUDED.col → mysql VALUES()/ sqlite excluded.);DialectForDB 从连接驱动推导
- 9 处 ON DUPLICATE KEY、11 处 FOR UPDATE 全走 dialect;sqlite 靠 _txlock=
  immediate 取得 BEGIN IMMEDIATE 悲观锁等价语义
- directory_version 三处重复合并为 store.BumpDirectoryVersion(dialect 感知)
- 这些文件同时含(2/4)的 UTC→Go 改动(与 upsert/锁同语句交错,无法拆分)
- 顺带:usage 的 FIELD()、codes 的 DATE_ADD/GREATEST 续期、nodes 的
  UNIX_TIMESTAMP、NULLIF 等 MySQL 专属构造一并退回 Go/可移植写法

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:01:26 +08:00

97 lines
3.2 KiB
Go

package db
import (
"database/sql"
"fmt"
"regexp"
"strings"
)
// Dialect captures the small set of SQL constructs that differ between MySQL
// and SQLite. The ~95% of the server's queries are plain CRUD with "?"
// placeholders and need no dialect handling; only upserts and row locks do.
type Dialect interface {
// Name reports "mysql" or "sqlite".
Name() string
// LockForUpdate returns the pessimistic row-lock clause to append to a
// SELECT inside a transaction:
// mysql → "FOR UPDATE"
// sqlite → "" (single-writer engine; the BEGIN IMMEDIATE transaction —
// enabled via _txlock=immediate in the DSN — already serializes
// writers, giving equivalent exclusivity).
LockForUpdate() string
// Upsert builds the conflict-resolution tail for an INSERT. conflictCols is
// the unique/primary key that triggers the conflict. setExprs are assignment
// expressions; refer to the would-be-inserted value with the sentinel
// "EXCLUDED.<col>" (rewritten to VALUES(col) on MySQL, kept as excluded.col
// on SQLite). With no setExprs the conflict is a no-op (insert-or-ignore).
//
// Example (atomic accumulate):
// d.Upsert([]string{"user_id","date"},
// "bytes_up = bytes_up + EXCLUDED.bytes_up")
Upsert(conflictCols []string, setExprs ...string) string
}
// DialectForDB derives the Dialect from the driver backing an open *sql.DB, so
// the dialect always matches the actual connection (no global state, and store
// constructors keep their (db *sql.DB) signatures).
func DialectForDB(database *sql.DB) Dialect {
if database == nil {
return MySQLDialect{}
}
name := strings.ToLower(fmt.Sprintf("%T", database.Driver()))
if strings.Contains(name, "sqlite") {
return SQLiteDialect{}
}
return MySQLDialect{}
}
// DialectFor returns the Dialect for a driver name ("mysql"|"sqlite").
func DialectFor(driver string) Dialect {
if Normalize(driver) == "sqlite" {
return SQLiteDialect{}
}
return MySQLDialect{}
}
var excludedRef = regexp.MustCompile(`EXCLUDED\.(\w+)`)
// MySQLDialect implements Dialect for MySQL.
type MySQLDialect struct{}
func (MySQLDialect) Name() string { return "mysql" }
func (MySQLDialect) LockForUpdate() string { return "FOR UPDATE" }
func (MySQLDialect) Upsert(conflictCols []string, setExprs ...string) string {
if len(setExprs) == 0 {
// No-op upsert (insert-or-ignore): assign a key column to itself.
col := "id"
if len(conflictCols) > 0 {
col = conflictCols[0]
}
return fmt.Sprintf("ON DUPLICATE KEY UPDATE %s = %s", col, col)
}
rewritten := make([]string, len(setExprs))
for i, e := range setExprs {
rewritten[i] = excludedRef.ReplaceAllString(e, "VALUES($1)")
}
return "ON DUPLICATE KEY UPDATE " + strings.Join(rewritten, ", ")
}
// SQLiteDialect implements Dialect for SQLite.
type SQLiteDialect struct{}
func (SQLiteDialect) Name() string { return "sqlite" }
func (SQLiteDialect) LockForUpdate() string { return "" }
func (SQLiteDialect) Upsert(conflictCols []string, setExprs ...string) string {
target := "ON CONFLICT(" + strings.Join(conflictCols, ", ") + ") "
if len(setExprs) == 0 {
return target + "DO NOTHING"
}
// SQLite accepts the EXCLUDED.<col> reference verbatim.
return target + "DO UPDATE SET " + strings.Join(setExprs, ", ")
}