- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
587 lines
23 KiB
Go
587 lines
23 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/healthlog/internal/canon"
|
|
)
|
|
|
|
// Роды таблиц сущностей. Тренировка живёт в своей таблице: у неё есть
|
|
// заголовок, по которому идёт выборка, а у записи его нет.
|
|
const (
|
|
workoutTable = "workout"
|
|
recordTable = "record"
|
|
)
|
|
|
|
// IncomingEntity — сущность с собственным идентификатором, пришедшая на запись.
|
|
//
|
|
// Содержимое хранится исходными байтами: сущность, пересобранная повторной
|
|
// сериализацией, теряет литерал ровно так же, как точка.
|
|
type IncomingEntity struct {
|
|
ID string
|
|
Kind string
|
|
Name string
|
|
|
|
Start time.Time
|
|
End time.Time
|
|
// OffsetSeconds — смещение зоны начала.
|
|
OffsetSeconds int
|
|
// Duration — длительность тренировки в секундах. nil означает «источник не
|
|
// прислал» и отличим от нуля: ноль — законная длительность.
|
|
Duration *float64
|
|
|
|
Raw json.RawMessage
|
|
}
|
|
|
|
// Incoming — всё, что дала одна доставка. Единицей записи является доставка, а
|
|
// не точка и не сущность: частичное состояние ломает инвариант «состояние
|
|
// пересобираемо».
|
|
type Incoming struct {
|
|
Points []IncomingPoint
|
|
Workouts []IncomingEntity
|
|
Records []IncomingEntity
|
|
}
|
|
|
|
// DeliveryRef — место доставки в журнале. Пара, а не идентификатор: по ней
|
|
// разрешается тай-брейк между версиями сущности равной полноты, а порядок
|
|
// журнала задан парой `(received_at, id)`.
|
|
type DeliveryRef struct {
|
|
ID string
|
|
ReceivedAt time.Time
|
|
}
|
|
|
|
func (d DeliveryRef) before(other DeliveryRef) bool {
|
|
if !d.ReceivedAt.Equal(other.ReceivedAt) {
|
|
return d.ReceivedAt.Before(other.ReceivedAt)
|
|
}
|
|
return d.ID < other.ID
|
|
}
|
|
|
|
// EntityRef — координаты сущности для записи в лог. Содержимого не несёт:
|
|
// маршрут тренировки — это геотрек до дома, а метки состояния разума —
|
|
// измерение душевного состояния.
|
|
type EntityRef struct {
|
|
Kind string
|
|
ID string
|
|
}
|
|
|
|
// maxEntityRefsReported — сколько координат сущностей попадает в лог.
|
|
const maxEntityRefsReported = 5
|
|
|
|
// prepareEntities считает хеш каждой сущности.
|
|
//
|
|
// Вынесено из транзакции намеренно: хеширование материализует значение целиком
|
|
// (маршрут — до мегабайта), а транзакция повторяется до пяти раз при занятости
|
|
// базы.
|
|
func prepareEntities(in []IncomingEntity, from DeliveryRef) ([]entityVersion, error) {
|
|
if len(in) == 0 {
|
|
return nil, nil
|
|
}
|
|
out := make([]entityVersion, 0, len(in))
|
|
for _, e := range in {
|
|
v, err := newEntityVersion(e, from)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// clipRefs держит список координат в потолке: он зацепка для разбора, а не
|
|
// отчёт; масштаб события считает счётчик.
|
|
func clipRefs(refs []EntityRef) []EntityRef {
|
|
if len(refs) <= maxEntityRefsReported {
|
|
return refs
|
|
}
|
|
return refs[:maxEntityRefsReported]
|
|
}
|
|
|
|
// entityVersion — версия сущности вместе с тем, что нужно знать при выборе
|
|
// победителя.
|
|
//
|
|
// Разбор и канонизация ОТЛОЖЕНЫ: они нужны только когда хеш разошёлся с
|
|
// сохранённым, то есть на одной доставке из сорока четырёх. Считать их сразу
|
|
// значило бы разворачивать маршрут (95% веса тренировки, до мегабайта) в дерево
|
|
// значений на каждой копии — ровно та форма, от которой разбор тела отказался
|
|
// замером (197 МиБ кучи против 54 МиБ на теле 42 МиБ). Хеш при этом считается
|
|
// сразу и один раз на доставку: он и есть быстрый путь.
|
|
type entityVersion struct {
|
|
raw json.RawMessage
|
|
hash string
|
|
from DeliveryRef
|
|
|
|
// key и fields заполняются лениво, методом analyze().
|
|
key []byte
|
|
fields canon.Fields
|
|
parsed bool
|
|
|
|
// head — заголовок, который пишется колонками. У сохранённой версии он не
|
|
// нужен: она либо побеждает и остаётся как есть, либо замещается целиком.
|
|
head IncomingEntity
|
|
}
|
|
|
|
func newEntityVersion(e IncomingEntity, from DeliveryRef) (entityVersion, error) {
|
|
h, err := canon.Hash(e.Raw)
|
|
if err != nil {
|
|
return entityVersion{}, fmt.Errorf("хеш сущности: %w", err)
|
|
}
|
|
return entityVersion{raw: e.Raw, hash: h, from: from, head: e}, nil
|
|
}
|
|
|
|
// analyze разбирает версию, если этого ещё не делали.
|
|
func (v *entityVersion) analyze() {
|
|
if v.parsed {
|
|
return
|
|
}
|
|
v.key = canon.SortKey(v.raw)
|
|
v.fields = canon.Analyze(v.raw)
|
|
v.parsed = true
|
|
}
|
|
|
|
// pickEntity выбирает между сохранённой и приехавшей версией.
|
|
//
|
|
// Второй возврат — потеряла ли бы витрина содержание, приняв приехавшую. Это и
|
|
// есть плата за отказ объединять поля: событие не предотвращается молча, а
|
|
// считается и уходит в WARN.
|
|
//
|
|
// 1. приехавшая несёт всё содержание сохранённой и сверх того → приехавшая
|
|
// 2. сохранённая несёт всё содержание приехавшей и сверх того → сохранённая
|
|
// 3. содержание равно → версия из более поздней доставки ЖУРНАЛА
|
|
// 4. наборы несравнимы → сохранённая
|
|
//
|
|
// Пункт 3 — не «побеждает приехавшая». Приехавшая есть функция порядка
|
|
// СВЁРТКИ, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
|
// только среди видимых ему доставок. Доставка с более ранней меткой, свёрнутая
|
|
// позже, вернула бы витрину к недосчитанной версии, и пересборка разошлась бы
|
|
// с живым приёмом молча — в содержимом тренировки, где это не видно ничем,
|
|
// кроме отпечатка.
|
|
//
|
|
// Равные позиции означают две версии одного ключа внутри ОДНОЙ доставки; там
|
|
// решает минимум канонической формы, потому что порядок элементов в
|
|
// JSON-массиве нестабилен.
|
|
func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) {
|
|
switch v := compareEntities(stored, incoming); v {
|
|
case entityIncomingRicher:
|
|
return true, false
|
|
case entityStoredRicher:
|
|
return false, true
|
|
case entityIncomparable:
|
|
// Несравнимы: у каждой версии есть содержание, которого нет у другой.
|
|
// Объединение полей отвергнуто там же и по той же причине, что для
|
|
// точек, — на живом потоке событие не наступало ни разу, — а из двух
|
|
// версий остаётся сохранённая: правило называется «не теряет
|
|
// содержания», и приехавшая его теряет. Исход при этом остаётся
|
|
// функцией журнала: доставки проигрываются в его порядке.
|
|
return false, true
|
|
default:
|
|
return laterInJournal(stored, incoming), false
|
|
}
|
|
}
|
|
|
|
// pickWithinDelivery выбирает между двумя версиями одного ключа ВНУТРИ одной
|
|
// доставки. Второй возврат — различается ли их содержание вообще.
|
|
//
|
|
// Отдельно от pickEntity, и не ради симметрии: «сохранённой» версии здесь нет,
|
|
// есть только порядок элементов в JSON-массиве, а он нестабилен. Правило
|
|
// «остаётся первая встреченная» сделало бы исход функцией порядка на проводе,
|
|
// поэтому при равном и при несравнимом содержании решает тотальный порядок
|
|
// канонических форм.
|
|
func pickWithinDelivery(a, b *entityVersion) (takeB, differs bool) {
|
|
switch v := compareEntities(a, b); v {
|
|
case entityIncomingRicher:
|
|
return true, true
|
|
case entityStoredRicher:
|
|
return false, true
|
|
case entityIncomparable:
|
|
return laterInJournal(a, b), true
|
|
default:
|
|
return laterInJournal(a, b), false
|
|
}
|
|
}
|
|
|
|
// entityVerdict — как соотносится СОДЕРЖАНИЕ двух версий одной сущности.
|
|
// Нумерация с единицы: нулевое значение не должно выглядеть как «равны».
|
|
type entityVerdict int
|
|
|
|
const (
|
|
// entityEqualContent — множества содержательных ключей совпадают, длины
|
|
// верхнеуровневых массивов тоже. Значения при этом могут расходиться: их
|
|
// сравнение здесь неприменимо (см. canon.Fields.Covers).
|
|
entityEqualContent entityVerdict = iota + 1
|
|
entityIncomingRicher
|
|
entityStoredRicher
|
|
entityIncomparable
|
|
)
|
|
|
|
func compareEntities(stored, incoming *entityVersion) entityVerdict {
|
|
stored.analyze()
|
|
incoming.analyze()
|
|
|
|
storedCovers := stored.fields.Covers(incoming.fields)
|
|
incomingCovers := incoming.fields.Covers(stored.fields)
|
|
|
|
switch {
|
|
case incomingCovers && storedCovers:
|
|
return entityEqualContent
|
|
case incomingCovers:
|
|
return entityIncomingRicher
|
|
case storedCovers:
|
|
return entityStoredRicher
|
|
default:
|
|
return entityIncomparable
|
|
}
|
|
}
|
|
|
|
// laterInJournal говорит, стоит ли приехавшая версия позже сохранённой в
|
|
// журнале. Позиции равны у двух версий одного ключа внутри одной доставки;
|
|
// там решает минимум канонической формы — порядок тотальный и от порядка
|
|
// элементов в массиве не зависит.
|
|
func laterInJournal(stored, incoming *entityVersion) bool {
|
|
if stored.from.before(incoming.from) {
|
|
return true
|
|
}
|
|
if incoming.from.before(stored.from) {
|
|
return false
|
|
}
|
|
return bytes.Compare(incoming.key, stored.key) < 0
|
|
}
|
|
|
|
// dedupeEntities сворачивает версии одного ключа ВНУТРИ доставки тем же
|
|
// правилом — до сравнения с сохранённой.
|
|
//
|
|
// Без этого исход зависел бы от того, как написан цикл: карта по ключу дала бы
|
|
// победу последнему элементу массива мимо правила полноты, а порядок элементов
|
|
// в JSON-массиве нестабилен.
|
|
//
|
|
// Счётчик здесь считает СИММЕТРИЧНО — «в одном теле приехали две версии одного
|
|
// ключа с разным содержанием», — а не «приехавшая обеднена». Внутри доставки
|
|
// «сохранённой» версии не существует, есть только порядок элементов массива, и
|
|
// счётчик, зависящий от него, наблюдал бы событие через раз.
|
|
func dedupeEntities(versions []entityVersion) ([]entityVersion, int, []EntityRef) {
|
|
type slot struct {
|
|
v entityVersion
|
|
pos int
|
|
}
|
|
|
|
byKey := make(map[EntityRef]slot, len(versions))
|
|
order := make([]EntityRef, 0, len(versions))
|
|
held := 0
|
|
var heldAt []EntityRef
|
|
|
|
for _, v := range versions {
|
|
ref := EntityRef{Kind: v.head.Kind, ID: v.head.ID}
|
|
prev, seen := byKey[ref]
|
|
if !seen {
|
|
byKey[ref] = slot{v: v, pos: len(order)}
|
|
order = append(order, ref)
|
|
continue
|
|
}
|
|
takeB, differs := pickWithinDelivery(&prev.v, &v)
|
|
if differs {
|
|
held++
|
|
if len(heldAt) < maxEntityRefsReported {
|
|
heldAt = append(heldAt, ref)
|
|
}
|
|
}
|
|
winner := prev.v
|
|
if takeB {
|
|
winner = v
|
|
}
|
|
byKey[ref] = slot{v: winner, pos: prev.pos}
|
|
}
|
|
|
|
out := make([]entityVersion, 0, len(order))
|
|
for _, ref := range order {
|
|
out = append(out, byKey[ref].v)
|
|
}
|
|
return out, held, heldAt
|
|
}
|
|
|
|
// mergeEntities сливает сущности одной секции с сохранёнными.
|
|
func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []entityVersion, now time.Time) (written, held int, heldAt []EntityRef, err error) {
|
|
for _, v := range versions {
|
|
stored, found, err := readEntityHead(ctx, tx, table, v.head.Kind, v.head.ID)
|
|
if err != nil {
|
|
return 0, 0, nil, err
|
|
}
|
|
|
|
if !found {
|
|
if err := writeEntity(ctx, tx, table, v, now); err != nil {
|
|
return 0, 0, nil, err
|
|
}
|
|
written++
|
|
continue
|
|
}
|
|
|
|
// Хеш — детектор изменений: совпал, значит писать нечего, и содержимое
|
|
// сохранённой сущности читать не приходится вовсе. Тренировка
|
|
// переприсылается каждой доставкой, пока не доедет маршрут, — на живом
|
|
// архиве 44 копии дают три различных содержимых.
|
|
if stored.hash == v.hash {
|
|
continue
|
|
}
|
|
|
|
storedRaw, err := readEntityPayload(ctx, tx, table, v.head.Kind, v.head.ID)
|
|
if err != nil {
|
|
return 0, 0, nil, err
|
|
}
|
|
prev := entityVersion{raw: storedRaw, hash: stored.hash, from: stored.from}
|
|
|
|
takeIncoming, lost := pickEntity(&prev, &v)
|
|
if lost {
|
|
held++
|
|
if len(heldAt) < maxEntityRefsReported {
|
|
heldAt = append(heldAt, EntityRef{Kind: v.head.Kind, ID: v.head.ID})
|
|
}
|
|
}
|
|
if !takeIncoming {
|
|
continue
|
|
}
|
|
if err := writeEntity(ctx, tx, table, v, now); err != nil {
|
|
return 0, 0, nil, err
|
|
}
|
|
written++
|
|
}
|
|
return written, held, heldAt, nil
|
|
}
|
|
|
|
type storedEntityHead struct {
|
|
hash string
|
|
from DeliveryRef
|
|
}
|
|
|
|
func readEntityHead(ctx context.Context, tx *sql.Tx, table, kind, id string) (storedEntityHead, bool, error) {
|
|
q := `SELECT content_hash, delivery_id, delivery_received_at FROM ` + table + entityWhere(table)
|
|
|
|
var (
|
|
head storedEntityHead
|
|
receivedAt string
|
|
)
|
|
row := queryEntity(ctx, tx, q, table, kind, id)
|
|
err := row.Scan(&head.hash, &head.from.ID, &receivedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return storedEntityHead{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return storedEntityHead{}, false, fmt.Errorf("select %s: %w", table, err)
|
|
}
|
|
// Пустую метку не терпим: колонка NOT NULL без умолчания, и пустота здесь
|
|
// означала бы дефект писателя. Молчаливый нулевой момент сделал бы
|
|
// сохранённую версию «самой ранней в журнале», и её затирала бы любая
|
|
// приехавшая — то есть дефект проявился бы потерей данных, а не отказом.
|
|
head.from.ReceivedAt, err = ParseTime(receivedAt)
|
|
if err != nil {
|
|
return storedEntityHead{}, false, err
|
|
}
|
|
return head, true, nil
|
|
}
|
|
|
|
func readEntityPayload(ctx context.Context, tx *sql.Tx, table, kind, id string) (json.RawMessage, error) {
|
|
q := `SELECT payload FROM ` + table + entityWhere(table)
|
|
|
|
var payload []byte
|
|
if err := queryEntity(ctx, tx, q, table, kind, id).Scan(&payload); err != nil {
|
|
return nil, fmt.Errorf("select %s payload: %w", table, err)
|
|
}
|
|
raw, err := gunzipBytes(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// entityWhere и queryEntity держат разницу между таблицами в одном месте:
|
|
// у тренировки ключ — `id`, у записи — пара `kind + id`.
|
|
func entityWhere(table string) string {
|
|
if table == recordTable {
|
|
return ` WHERE kind = ? AND id = ?`
|
|
}
|
|
return ` WHERE id = ?`
|
|
}
|
|
|
|
func queryEntity(ctx context.Context, tx *sql.Tx, q, table, kind, id string) *sql.Row {
|
|
if table == recordTable {
|
|
return tx.QueryRowContext(ctx, q, kind, id)
|
|
}
|
|
return tx.QueryRowContext(ctx, q, id)
|
|
}
|
|
|
|
func writeEntity(ctx context.Context, tx *sql.Tx, table string, v entityVersion, now time.Time) error {
|
|
payload, err := gzipBytes(v.raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stamp := FormatTime(now)
|
|
received := FormatTime(v.from.ReceivedAt)
|
|
|
|
if table == recordTable {
|
|
const q = `
|
|
INSERT INTO record (kind, id, ts_utc, tz_offset, payload, content_hash,
|
|
delivery_id, delivery_received_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (kind, id) DO UPDATE SET
|
|
ts_utc = excluded.ts_utc,
|
|
tz_offset = excluded.tz_offset,
|
|
payload = excluded.payload,
|
|
content_hash = excluded.content_hash,
|
|
delivery_id = excluded.delivery_id,
|
|
delivery_received_at = excluded.delivery_received_at,
|
|
updated_at = excluded.updated_at`
|
|
|
|
if _, err := tx.ExecContext(ctx, q,
|
|
v.head.Kind, v.head.ID, FormatTime(v.head.Start), v.head.OffsetSeconds,
|
|
payload, v.hash, v.from.ID, received, stamp, stamp); err != nil {
|
|
return fmt.Errorf("upsert record: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const q = `
|
|
INSERT INTO workout (id, name, start_utc, end_utc, tz_offset, duration_sec,
|
|
payload, content_hash, delivery_id, delivery_received_at,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
name = excluded.name,
|
|
start_utc = excluded.start_utc,
|
|
end_utc = excluded.end_utc,
|
|
tz_offset = excluded.tz_offset,
|
|
duration_sec = excluded.duration_sec,
|
|
payload = excluded.payload,
|
|
content_hash = excluded.content_hash,
|
|
delivery_id = excluded.delivery_id,
|
|
delivery_received_at = excluded.delivery_received_at,
|
|
updated_at = excluded.updated_at`
|
|
|
|
var duration any
|
|
if v.head.Duration != nil {
|
|
duration = *v.head.Duration
|
|
}
|
|
if _, err := tx.ExecContext(ctx, q,
|
|
v.head.ID, v.head.Name, FormatTime(v.head.Start), FormatTime(v.head.End),
|
|
v.head.OffsetSeconds, duration, payload, v.hash, v.from.ID, received,
|
|
stamp, stamp); err != nil {
|
|
return fmt.Errorf("upsert workout: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Workout — тренировка, прочитанная из витрины. Нужна тестам и будущему
|
|
// Read API: содержимое отдаётся целиком, заголовок — из колонок.
|
|
type Workout struct {
|
|
ID string
|
|
Name string
|
|
Start time.Time
|
|
End time.Time
|
|
OffsetSeconds int
|
|
Duration *float64
|
|
Raw json.RawMessage
|
|
Delivery string
|
|
}
|
|
|
|
// Workout читает тренировку по идентификатору.
|
|
func (s *Store) Workout(ctx context.Context, id string) (Workout, error) {
|
|
const q = `
|
|
SELECT id, name, start_utc, end_utc, tz_offset, duration_sec, payload, delivery_id
|
|
FROM workout WHERE id = ?`
|
|
|
|
var (
|
|
w Workout
|
|
start, end string
|
|
duration sql.NullFloat64
|
|
payload []byte
|
|
deliveryFrom string
|
|
)
|
|
err := s.db.QueryRowxContext(ctx, q, id).
|
|
Scan(&w.ID, &w.Name, &start, &end, &w.OffsetSeconds, &duration, &payload, &deliveryFrom)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Workout{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Workout{}, fmt.Errorf("select workout: %w", err)
|
|
}
|
|
|
|
if w.Start, err = ParseTime(start); err != nil {
|
|
return Workout{}, err
|
|
}
|
|
if w.End, err = ParseTime(end); err != nil {
|
|
return Workout{}, err
|
|
}
|
|
if duration.Valid {
|
|
v := duration.Float64
|
|
w.Duration = &v
|
|
}
|
|
if w.Raw, err = gunzipBytes(payload); err != nil {
|
|
return Workout{}, err
|
|
}
|
|
w.Delivery = deliveryFrom
|
|
return w, nil
|
|
}
|
|
|
|
// Record — запись секции с собственным идентификатором.
|
|
type Record struct {
|
|
Kind string
|
|
ID string
|
|
TS time.Time
|
|
OffsetSeconds int
|
|
Raw json.RawMessage
|
|
Delivery string
|
|
}
|
|
|
|
// Record читает запись по роду и идентификатору.
|
|
func (s *Store) Record(ctx context.Context, kind, id string) (Record, error) {
|
|
const q = `
|
|
SELECT kind, id, ts_utc, tz_offset, payload, delivery_id
|
|
FROM record WHERE kind = ? AND id = ?`
|
|
|
|
var (
|
|
r Record
|
|
ts string
|
|
payload []byte
|
|
deliveryFrom string
|
|
)
|
|
err := s.db.QueryRowxContext(ctx, q, kind, id).
|
|
Scan(&r.Kind, &r.ID, &ts, &r.OffsetSeconds, &payload, &deliveryFrom)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Record{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Record{}, fmt.Errorf("select record: %w", err)
|
|
}
|
|
|
|
if r.TS, err = ParseTime(ts); err != nil {
|
|
return Record{}, err
|
|
}
|
|
if r.Raw, err = gunzipBytes(payload); err != nil {
|
|
return Record{}, err
|
|
}
|
|
r.Delivery = deliveryFrom
|
|
return r, nil
|
|
}
|
|
|
|
// CountWorkouts и CountRecords нужны отчёту пересборки: отпечаток отвечает
|
|
// «да/нет», а по «да/нет» нельзя судить о направлении расхождения.
|
|
func (s *Store) CountWorkouts(ctx context.Context) (int64, error) {
|
|
var n int64
|
|
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM workout`); err != nil {
|
|
return 0, fmt.Errorf("count workouts: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// CountRecords возвращает число записей секций с собственным `id`.
|
|
func (s *Store) CountRecords(ctx context.Context) (int64, error) {
|
|
var n int64
|
|
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM record`); err != nil {
|
|
return 0, fmt.Errorf("count records: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|