- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
191 lines
7.3 KiB
Go
191 lines
7.3 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
sqlitedriver "modernc.org/sqlite"
|
|
sqlitelib "modernc.org/sqlite/lib"
|
|
)
|
|
|
|
// timeLayout — единственный вид времени в схеме: RFC 3339, UTC, суффикс `Z`,
|
|
// секундная точность.
|
|
//
|
|
// Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT`
|
|
// совпадает с хронологией, и отбор по колонке времени работает без разбора
|
|
// значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё
|
|
// положили, — колонка, заполненная то одним видом, то другим, обратила бы
|
|
// условие срока протухания захвата в постоянную истину или ложь молча, и запись
|
|
// не выдавалась бы ни одному воркеру никогда.
|
|
const timeLayout = "2006-01-02T15:04:05Z"
|
|
|
|
// formatTime приводит метку времени к виду колонки.
|
|
func formatTime(v time.Time) string {
|
|
return v.UTC().Format(timeLayout)
|
|
}
|
|
|
|
// timeValue кладёт время в колонку, допускающую пустое значение. Нулевое время
|
|
// и отсутствующее — одно и то же: «времени нет».
|
|
func timeValue(v *time.Time) any {
|
|
if v == nil || v.IsZero() {
|
|
return nil
|
|
}
|
|
return formatTime(*v)
|
|
}
|
|
|
|
// timeOf читает колонку времени. Нечитаемое значение отдаётся нулевым: колонка
|
|
// пишется только нами, и разбор здесь — сторож, а не ветвь поведения.
|
|
func timeOf(v sql.NullString) *time.Time {
|
|
if !v.Valid || v.String == "" {
|
|
return nil
|
|
}
|
|
parsed, err := time.Parse(timeLayout, v.String)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
parsed = parsed.UTC()
|
|
return &parsed
|
|
}
|
|
|
|
// requiredTimeOf читает обязательную колонку времени.
|
|
func requiredTimeOf(v string) time.Time {
|
|
parsed, err := time.Parse(timeLayout, v)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
return parsed.UTC()
|
|
}
|
|
|
|
// stringValue кладёт необязательную строку: пустая и отсутствующая — одно и то
|
|
// же.
|
|
func stringValue(v *string) any {
|
|
if v == nil || *v == "" {
|
|
return nil
|
|
}
|
|
return *v
|
|
}
|
|
|
|
// stringOf читает необязательную строку.
|
|
func stringOf(v sql.NullString) *string {
|
|
if !v.Valid || v.String == "" {
|
|
return nil
|
|
}
|
|
out := v.String
|
|
return &out
|
|
}
|
|
|
|
// numberOf читает необязательное число.
|
|
//
|
|
// Указатель здесь не выражает «неизвестно»: обе величины записи ставит приём и
|
|
// ставит всегда, а колонки объявлены обязательными. Форма осталась указателем
|
|
// потому, что её несёт домен, а ответ приложению обязан различать поле и его
|
|
// отсутствие.
|
|
func numberOf(v int64) *int64 {
|
|
out := v
|
|
return &out
|
|
}
|
|
|
|
// numberValue кладёт необязательное число нулём: колонка обязательна.
|
|
func numberValue(v *int64) int64 {
|
|
if v == nil {
|
|
return 0
|
|
}
|
|
return *v
|
|
}
|
|
|
|
// insertSQL собирает вставку из карты «колонка → значение».
|
|
//
|
|
// Именованными параметрами, а не позиционным списком: у аудиозаписи поля одного
|
|
// типа идут длинным непрерывным рядом, и позиционный сдвиг на одно поле
|
|
// скомпилировался бы молча, положив идентификатор файла в колонку текста. По
|
|
// имени такого сдвига не существует вовсе.
|
|
//
|
|
// Порядок колонок берётся сортировкой, а не порядком обхода карты: обход карты
|
|
// в Go случаен, и текст запроса менялся бы от прогона к прогону — отладка по
|
|
// журналу читала бы каждый раз новый запрос.
|
|
func insertSQL(table string, values map[string]any) (string, []any) {
|
|
names := sortedNames(values)
|
|
|
|
placeholders := make([]string, 0, len(names))
|
|
args := make([]any, 0, len(names))
|
|
for _, name := range names {
|
|
placeholders = append(placeholders, ":"+name)
|
|
args = append(args, sql.Named(name, values[name]))
|
|
}
|
|
|
|
query := fmt.Sprintf(
|
|
"INSERT INTO %s (%s) VALUES (%s)",
|
|
table,
|
|
strings.Join(names, ", "),
|
|
strings.Join(placeholders, ", "),
|
|
)
|
|
|
|
return query, args
|
|
}
|
|
|
|
// updateSQL собирает правку из карты «колонка → значение» и условия.
|
|
func updateSQL(table string, values map[string]any, where string, whereArgs []any) (string, []any) {
|
|
names := sortedNames(values)
|
|
|
|
assignments := make([]string, 0, len(names))
|
|
args := make([]any, 0, len(names)+len(whereArgs))
|
|
for _, name := range names {
|
|
assignments = append(assignments, name+" = :"+name)
|
|
args = append(args, sql.Named(name, values[name]))
|
|
}
|
|
args = append(args, whereArgs...)
|
|
|
|
query := fmt.Sprintf(
|
|
"UPDATE %s SET %s WHERE %s",
|
|
table,
|
|
strings.Join(assignments, ", "),
|
|
where,
|
|
)
|
|
|
|
return query, args
|
|
}
|
|
|
|
func sortedNames(values map[string]any) []string {
|
|
names := make([]string, 0, len(values))
|
|
for name := range values {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
// selectList собирает перечень колонок для выборки из той же карты, по которой
|
|
// потом идёт чтение. Один источник у обеих половин: колонка, забытая в перечне,
|
|
// не имеет места назначения, и наоборот — расхождению взяться неоткуда.
|
|
func selectList(targets map[string]any, prefix string) (string, []any) {
|
|
names := sortedNames(targets)
|
|
|
|
columns := make([]string, 0, len(names))
|
|
scan := make([]any, 0, len(names))
|
|
for _, name := range names {
|
|
columns = append(columns, prefix+name)
|
|
scan = append(scan, targets[name])
|
|
}
|
|
|
|
return strings.Join(columns, ", "), scan
|
|
}
|
|
|
|
// isUniqueViolation говорит, отказала ли запись по уникальному индексу.
|
|
//
|
|
// Судится **код** отказа, а не его текст: текст у драйвера свой на каждую
|
|
// версию, а узнавание ошибки по тексту запрещено правилом проекта. Какая именно
|
|
// колонка не сошлась, код не называет — и это не мешает: заведение учётной
|
|
// записи различает два отказа повторным поиском по ключу, а не разбором текста.
|
|
func isUniqueViolation(err error) bool {
|
|
var sqliteErr *sqlitedriver.Error
|
|
if !errors.As(err, &sqliteErr) {
|
|
return false
|
|
}
|
|
return sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_UNIQUE ||
|
|
sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY
|
|
}
|