хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user