Files
transcriber/internal/adapter/repo/sqlite/values.go
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

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
}