добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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] + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user