- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
151 lines
7.5 KiB
Go
151 lines
7.5 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"time"
|
||
)
|
||
|
||
// CategoryValue — строка реестра категориальных значений: перечислимая строка,
|
||
// которую приносил поток, и выведенный для неё код HealthKit.
|
||
//
|
||
// Значение хранится дословно; код — кэш чистой функции от словаря в бинаре, и в
|
||
// отпечаток витрины он не входит (см. fingerprintCategories).
|
||
type CategoryValue struct {
|
||
// Metric — имя метрики или секции, ТО ЖЕ, которым адресуется единица
|
||
// хранения.
|
||
Metric string
|
||
// Field — имя поля внутри точки или сущности, дословно как у HAE.
|
||
Field string
|
||
// Value — строка, как прислал HAE.
|
||
Value string
|
||
// Code — канонический код HealthKit; пустой означает «словарь не знает».
|
||
Code string
|
||
|
||
// FirstSeen и FirstDeliveryID — провенанс ПЕРВОЙ встречи по порядку журнала.
|
||
// Заполняются хранилищем, вызывающему при записи не нужны.
|
||
FirstSeen time.Time
|
||
FirstDeliveryID string
|
||
}
|
||
|
||
// mergeCategories записывает наблюдения доставки в реестр.
|
||
//
|
||
// Правило записи одно и оно решает всё: код обновляется ВСЕГДА, провенанс —
|
||
// только вниз, к более раннему месту в журнале. Иначе повторная свёртка той же
|
||
// доставки меняла бы состояние, а проигрывание журнала в порядке `(received_at,
|
||
// id)` давало бы не то, что живой приём.
|
||
//
|
||
// Сравнение провенанса идёт парой `(received_at, id)`, а не одной меткой:
|
||
// доставки одной секунды в журнале упорядочены идентификатором, и сравнение по
|
||
// одной метке сделало бы исход зависящим от того, какая из них свернулась
|
||
// раньше. Пара выражена лексикографически прямо в SQL — переносить сравнение в
|
||
// Go значило бы читать строку, решать и писать, то есть добавлять обращение к
|
||
// базе на каждое наблюдение внутри транзакции.
|
||
func mergeCategories(ctx context.Context, tx *sql.Tx, values []CategoryValue, from DeliveryRef) error {
|
||
if len(values) == 0 {
|
||
return nil
|
||
}
|
||
|
||
const q = `
|
||
INSERT INTO category_value
|
||
(metric, field, value, code, first_seen_utc, first_delivery_id)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT (metric, field, value) DO UPDATE SET
|
||
code = excluded.code,
|
||
first_seen_utc = CASE
|
||
WHEN (excluded.first_seen_utc, excluded.first_delivery_id)
|
||
< (category_value.first_seen_utc, category_value.first_delivery_id)
|
||
THEN excluded.first_seen_utc ELSE category_value.first_seen_utc END,
|
||
first_delivery_id = CASE
|
||
WHEN (excluded.first_seen_utc, excluded.first_delivery_id)
|
||
< (category_value.first_seen_utc, category_value.first_delivery_id)
|
||
THEN excluded.first_delivery_id ELSE category_value.first_delivery_id END`
|
||
|
||
stmt, err := tx.PrepareContext(ctx, q)
|
||
if err != nil {
|
||
return fmt.Errorf("prepare category upsert: %w", err)
|
||
}
|
||
defer func() { _ = stmt.Close() }()
|
||
|
||
at := FormatTime(from.ReceivedAt)
|
||
for _, v := range values {
|
||
if _, err := stmt.ExecContext(ctx, v.Metric, v.Field, v.Value, v.Code, at, from.ID); err != nil {
|
||
// Значение в текст ошибки не попадает: строка категориального
|
||
// значения — данные о здоровье наравне со значением точки.
|
||
//
|
||
// Координаты обрезаются, хотя сегодня они приходят из объявленного
|
||
// списка полей и произвольной строкой из тела быть не могут. Предел
|
||
// стоит здесь, а не держится на форме того списка: правило вида
|
||
// «поле `value` у метрик с таким-то именем» уронило бы имя метрики
|
||
// из чужого тела прямо в `ERROR`, и заметить это было бы нечем.
|
||
return fmt.Errorf("upsert category value (%s/%s): %w",
|
||
clipCoord(v.Metric), clipCoord(v.Field), err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CategoryValues возвращает реестр целиком в детерминированном порядке.
|
||
//
|
||
// Реестр мал по построению — на живом потоке различных значений около
|
||
// одиннадцати, — поэтому страничного чтения у него нет и заводить его незачем:
|
||
// границы разбора не дают доставке положить больше 64 значений, а число полей
|
||
// объявлено списком.
|
||
func (s *Store) CategoryValues(ctx context.Context) ([]CategoryValue, error) {
|
||
const q = `
|
||
SELECT metric, field, value, code, first_seen_utc, first_delivery_id
|
||
FROM category_value ORDER BY metric, field, value`
|
||
|
||
rows, err := s.db.QueryContext(ctx, q)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("select category values: %w", err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
var out []CategoryValue
|
||
for rows.Next() {
|
||
var v CategoryValue
|
||
var firstSeen string
|
||
if err := rows.Scan(&v.Metric, &v.Field, &v.Value, &v.Code, &firstSeen, &v.FirstDeliveryID); err != nil {
|
||
return nil, fmt.Errorf("scan category value: %w", err)
|
||
}
|
||
v.FirstSeen, err = ParseTime(firstSeen)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, v)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("select category values: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// CountCategoryValues возвращает число строк реестра.
|
||
//
|
||
// Нужно отчёту пересборки: реестр — единица хранения витрины, и без счётчика
|
||
// «до и после» расхождение отпечатков по нему безадресно — числа объектов,
|
||
// тренировок и записей при этом не меняются. Перечислением отвечать нельзя:
|
||
// сами строки — данные о здоровье, а отчёт печатается человеку в терминал.
|
||
func (s *Store) CountCategoryValues(ctx context.Context) (int64, error) {
|
||
var n int64
|
||
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM category_value`); err != nil {
|
||
return 0, fmt.Errorf("count category values: %w", err)
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// maxCoordInLog — предел длины координаты наблюдения в тексте ошибки.
|
||
//
|
||
// Та же граница и по той же причине, что у имени метрики в координатах
|
||
// столкновения: значение из тела ничем не ограничено, а предел приёма — 64 МиБ.
|
||
const maxCoordInLog = 64
|
||
|
||
func clipCoord(s string) string {
|
||
if len(s) <= maxCoordInLog {
|
||
return s
|
||
}
|
||
return s[:maxCoordInLog] + "…"
|
||
}
|