7686 lines
332 KiB
C
7686 lines
332 KiB
C
/**
|
|
* @file database.c
|
|
* @brief SQLite persistence implementation for Trainlog.
|
|
*/
|
|
|
|
#include "trainlog/database.h"
|
|
#include "trainlog/body_zone_catalog.h"
|
|
#include "trainlog/duration.h"
|
|
#include "trainlog/equipment_catalog.h"
|
|
#include "trainlog/id.h"
|
|
#include "trainlog/session_generation.h"
|
|
#include "timestamp.h"
|
|
#include "database_internal.h"
|
|
|
|
#include <math.h>
|
|
#include <limits.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#include <sqlite3.h>
|
|
#include <uuid/uuid.h>
|
|
|
|
/* CONTRACT: exact parity with Android/Python UUIDv5 over parent, profile tuple. */
|
|
static void sqlite_profile_revision(sqlite3_context *context, int argc, sqlite3_value **argv) {
|
|
uuid_t namespace_id, revision;
|
|
char name[256], uuid_text[37], output[41];
|
|
const char *parent, *recording, *tracking;
|
|
int length;
|
|
if (argc != 4 || sqlite3_value_type(argv[0]) != SQLITE_TEXT ||
|
|
sqlite3_value_type(argv[1]) != SQLITE_TEXT || sqlite3_value_type(argv[2]) != SQLITE_TEXT ||
|
|
sqlite3_value_type(argv[3]) != SQLITE_INTEGER) {
|
|
sqlite3_result_error(context, "invalid profile revision", -1);
|
|
return;
|
|
}
|
|
parent = (const char *)sqlite3_value_text(argv[0]);
|
|
recording = (const char *)sqlite3_value_text(argv[1]);
|
|
tracking = (const char *)sqlite3_value_text(argv[2]);
|
|
length = snprintf(name,
|
|
sizeof(name),
|
|
"%s\n%s\n%s\n%d",
|
|
parent,
|
|
recording,
|
|
tracking,
|
|
sqlite3_value_int(argv[3]));
|
|
if (length < 0 || (size_t)length >= sizeof(name) ||
|
|
uuid_parse("4f4c8ea6-18f6-5e48-9d65-a54a24889149", namespace_id) != 0) {
|
|
sqlite3_result_error(context, "profile revision overflow", -1);
|
|
return;
|
|
}
|
|
uuid_generate_sha1(revision, namespace_id, name, (size_t)length);
|
|
uuid_unparse_lower(revision, uuid_text);
|
|
(void)snprintf(output, sizeof(output), "pr2_%s", uuid_text);
|
|
sqlite3_result_text(context, output, -1, SQLITE_TRANSIENT);
|
|
}
|
|
|
|
/* Exact recognizer for the unpublished variable-width prototype token. */
|
|
static void
|
|
sqlite_prototype_profile_valid(sqlite3_context *context, int argc, sqlite3_value **argv) {
|
|
const char *token, *recording, *tracking;
|
|
char expected[96];
|
|
int fields, length;
|
|
if (argc != 4 || sqlite3_value_type(argv[0]) != SQLITE_TEXT ||
|
|
sqlite3_value_type(argv[1]) != SQLITE_TEXT || sqlite3_value_type(argv[2]) != SQLITE_TEXT ||
|
|
sqlite3_value_type(argv[3]) != SQLITE_INTEGER) {
|
|
sqlite3_result_int(context, 0);
|
|
return;
|
|
}
|
|
token = (const char *)sqlite3_value_text(argv[0]);
|
|
recording = (const char *)sqlite3_value_text(argv[1]);
|
|
tracking = (const char *)sqlite3_value_text(argv[2]);
|
|
fields = sqlite3_value_int(argv[3]);
|
|
if ((strcmp(recording, "sets") != 0 && strcmp(recording, "continuous") != 0) ||
|
|
(strcmp(tracking, "reps") != 0 && strcmp(tracking, "duration") != 0) ||
|
|
(strcmp(recording, "continuous") == 0 && strcmp(tracking, "duration") != 0) || fields < 0 ||
|
|
(fields & ~3) != 0 || (strcmp(recording, "sets") == 0 && fields != 0)) {
|
|
sqlite3_result_int(context, 0);
|
|
return;
|
|
}
|
|
length = snprintf(expected, sizeof(expected), "pr1|%s|%s|%d", recording, tracking, fields);
|
|
sqlite3_result_int(
|
|
context, length >= 0 && (size_t)length < sizeof(expected) && strcmp(token, expected) == 0);
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_read_snapshot_begin(TrainlogDatabase *database) {
|
|
char sql[64];
|
|
if (database == NULL || database->connection == NULL ||
|
|
database->read_snapshot_depth == UINT_MAX) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
(void)snprintf(sql, sizeof(sql), "SAVEPOINT trainlog_read_%u;", database->read_snapshot_depth);
|
|
if (sqlite3_exec(database->connection, sql, NULL, NULL, NULL) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
++database->read_snapshot_depth;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_read_snapshot_end(TrainlogDatabase *database,
|
|
bool commit_snapshot) {
|
|
char sql[160];
|
|
unsigned int depth;
|
|
if (database == NULL || database->connection == NULL || database->read_snapshot_depth == 0U) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
depth = database->read_snapshot_depth - 1U;
|
|
if (commit_snapshot) {
|
|
(void)snprintf(sql, sizeof(sql), "RELEASE trainlog_read_%u;", depth);
|
|
} else {
|
|
(void)snprintf(sql,
|
|
sizeof(sql),
|
|
"ROLLBACK TO trainlog_read_%u; RELEASE trainlog_read_%u;",
|
|
depth,
|
|
depth);
|
|
}
|
|
if (sqlite3_exec(database->connection, sql, NULL, NULL, NULL) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
database->read_snapshot_depth = depth;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_scan_generation_history(TrainlogDatabase *database,
|
|
TrainlogGenerationHistoryVisitor visitor,
|
|
void *context) {
|
|
static const char *const SQL =
|
|
"SELECT s.session_id,s.started_at,se.entry_id,e.exercise_id,e.recording_mode,"
|
|
"se.tracking_mode,se.equipment_id,se.load_mode,se.rest_seconds,"
|
|
"se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg,"
|
|
"ps.position,ps.reps,ps.weight_kg,mr.max_weight_kg "
|
|
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id "
|
|
"JOIN exercises e ON e.id=se.exercise_row_id "
|
|
"LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id "
|
|
"LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id "
|
|
"ORDER BY s.session_id COLLATE BINARY,se.entry_id COLLATE BINARY,ps.position;";
|
|
sqlite3_stmt *statement = NULL;
|
|
TrainlogStatus status;
|
|
int rc;
|
|
if (database == NULL || visitor == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
status = trainlog_database_read_snapshot_begin(database);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK) {
|
|
(void)trainlog_database_read_snapshot_end(database, false);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
TrainlogGenerationHistoryRow row;
|
|
const unsigned char *recording, *tracking, *load;
|
|
int index;
|
|
(void)memset(&row, 0, sizeof(row));
|
|
/* CONTRACT: SQLite affinity cannot turn corrupt dynamic types into a
|
|
* complete scientific result; validate every projected field before
|
|
* the borrowed callback row is exposed. */
|
|
for (index = 0; index <= 3; ++index) {
|
|
if (sqlite3_column_type(statement, index) != SQLITE_TEXT) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
}
|
|
for (index = 4; index <= 5; ++index) {
|
|
if (sqlite3_column_type(statement, index) != SQLITE_TEXT) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
}
|
|
if (sqlite3_column_type(statement, 6) != SQLITE_NULL &&
|
|
sqlite3_column_type(statement, 6) != SQLITE_TEXT) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
if (sqlite3_column_type(statement, 7) != SQLITE_TEXT ||
|
|
sqlite3_column_type(statement, 8) != SQLITE_INTEGER) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
row.session_id = (const char *)sqlite3_column_text(statement, 0);
|
|
row.started_at = (const char *)sqlite3_column_text(statement, 1);
|
|
row.occurrence_id = (const char *)sqlite3_column_text(statement, 2);
|
|
row.exercise_id = (const char *)sqlite3_column_text(statement, 3);
|
|
recording = sqlite3_column_text(statement, 4);
|
|
tracking = sqlite3_column_text(statement, 5);
|
|
row.equipment_id = sqlite3_column_type(statement, 6) == SQLITE_NULL
|
|
? NULL
|
|
: (const char *)sqlite3_column_text(statement, 6);
|
|
load = sqlite3_column_text(statement, 7);
|
|
if (strcmp((const char *)recording, "sets") == 0) {
|
|
row.recording_mode = TRAINLOG_RECORDING_SETS;
|
|
} else if (strcmp((const char *)recording, "continuous") == 0) {
|
|
row.recording_mode = TRAINLOG_RECORDING_CONTINUOUS;
|
|
} else {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
if (strcmp((const char *)tracking, "reps") == 0) {
|
|
row.tracking_mode = TRAINLOG_TRACKING_REPS;
|
|
} else if (strcmp((const char *)tracking, "duration") == 0) {
|
|
row.tracking_mode = TRAINLOG_TRACKING_DURATION;
|
|
} else {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
if (strcmp((const char *)load, "none") == 0) {
|
|
row.load_mode = TRAINLOG_LOAD_NONE;
|
|
} else if (strcmp((const char *)load, "external") == 0) {
|
|
row.load_mode = TRAINLOG_LOAD_EXTERNAL;
|
|
} else if (strcmp((const char *)load, "assistance") == 0) {
|
|
row.load_mode = TRAINLOG_LOAD_ASSISTANCE;
|
|
} else {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
row.rest_seconds = sqlite3_column_int(statement, 8);
|
|
row.has_target_sets = sqlite3_column_type(statement, 9) != SQLITE_NULL;
|
|
row.has_target_reps = sqlite3_column_type(statement, 10) != SQLITE_NULL;
|
|
row.has_target_duration = sqlite3_column_type(statement, 11) != SQLITE_NULL;
|
|
row.has_target_weight = sqlite3_column_type(statement, 12) != SQLITE_NULL;
|
|
row.has_actual_set = sqlite3_column_type(statement, 13) != SQLITE_NULL;
|
|
row.has_explicit_max = sqlite3_column_type(statement, 16) != SQLITE_NULL;
|
|
if (row.has_explicit_max && ((sqlite3_column_type(statement, 16) != SQLITE_FLOAT &&
|
|
sqlite3_column_type(statement, 16) != SQLITE_INTEGER) ||
|
|
!isfinite(sqlite3_column_double(statement, 16)) ||
|
|
sqlite3_column_double(statement, 16) <= 0.0)) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
if (row.has_actual_set) {
|
|
sqlite3_int64 position = sqlite3_column_int64(statement, 13);
|
|
if (sqlite3_column_type(statement, 13) != SQLITE_INTEGER || position < 0 ||
|
|
(uint64_t)position > (uint64_t)SIZE_MAX ||
|
|
(sqlite3_column_type(statement, 14) != SQLITE_INTEGER &&
|
|
sqlite3_column_type(statement, 14) != SQLITE_NULL)) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
row.set_position = (size_t)position;
|
|
row.repetitions = sqlite3_column_type(statement, 14) == SQLITE_NULL
|
|
? 0
|
|
: sqlite3_column_int(statement, 14);
|
|
row.has_weight = sqlite3_column_type(statement, 15) != SQLITE_NULL;
|
|
if (row.has_weight) {
|
|
if (sqlite3_column_type(statement, 15) != SQLITE_FLOAT &&
|
|
sqlite3_column_type(statement, 15) != SQLITE_INTEGER) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
row.weight_kg = sqlite3_column_double(statement, 15);
|
|
}
|
|
}
|
|
status = visitor(context, &row);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto done;
|
|
}
|
|
}
|
|
status = rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
done:
|
|
if (sqlite3_finalize(statement) != SQLITE_OK && status == TRAINLOG_STATUS_OK) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
{
|
|
TrainlogStatus end_status =
|
|
trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = end_status;
|
|
}
|
|
}
|
|
return status;
|
|
}
|
|
|
|
static TrainlogStatus lookup_exercise_row_id(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
sqlite3_int64 *output_row_id);
|
|
|
|
static bool custom_equipment_exists(TrainlogDatabase *database, const char *equipment_id) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc;
|
|
bool found = false;
|
|
if (database == NULL || equipment_id == NULL) {
|
|
return false;
|
|
}
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"SELECT 1 FROM custom_equipment WHERE equipment_id=?1;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return false;
|
|
}
|
|
(void)sqlite3_bind_text(statement, 1, equipment_id, -1, SQLITE_TRANSIENT);
|
|
found = sqlite3_step(statement) == SQLITE_ROW;
|
|
(void)sqlite3_finalize(statement);
|
|
return found;
|
|
}
|
|
|
|
static void set_open_diagnostic(
|
|
char *output, size_t capacity, const char *operation, sqlite3 *connection, int sqlite_status) {
|
|
const char *message;
|
|
int extended_status = sqlite_status;
|
|
|
|
if (output == NULL || capacity == 0U) {
|
|
return;
|
|
}
|
|
|
|
if (connection != NULL) {
|
|
message = sqlite3_errmsg(connection);
|
|
sqlite_status = sqlite3_errcode(connection);
|
|
extended_status = sqlite3_extended_errcode(connection);
|
|
} else {
|
|
message = sqlite3_errstr(sqlite_status);
|
|
}
|
|
/* WHY: preserve the actionable SQLite result while the owning connection
|
|
* is still open; rollback/close may replace its diagnostic state. */
|
|
(void)snprintf(output,
|
|
capacity,
|
|
"%s: SQLite rc=%d extended_rc=%d: %s",
|
|
operation,
|
|
sqlite_status,
|
|
extended_status,
|
|
message);
|
|
}
|
|
|
|
static void set_application_diagnostic(char *output, size_t capacity, const char *message) {
|
|
if (output == NULL || capacity == 0U) {
|
|
return;
|
|
}
|
|
(void)snprintf(output, capacity, "%s", message);
|
|
}
|
|
|
|
static const char *const SCHEMA_V7_SQL_A =
|
|
"BEGIN IMMEDIATE;"
|
|
|
|
"CREATE TABLE IF NOT EXISTS exercises ("
|
|
" id INTEGER PRIMARY KEY,"
|
|
" exercise_id TEXT NOT NULL UNIQUE,"
|
|
" name TEXT NOT NULL,"
|
|
" normalized_name TEXT NOT NULL UNIQUE,"
|
|
" tracking_mode TEXT NOT NULL"
|
|
" CHECK (tracking_mode IN ('reps', 'duration')),"
|
|
" recording_mode TEXT NOT NULL DEFAULT 'sets'"
|
|
" CHECK (recording_mode IN ('sets', 'continuous')),"
|
|
" data_fields INTEGER NOT NULL DEFAULT 0"
|
|
" CHECK (data_fields >= 0 AND (data_fields & ~3) = 0),"
|
|
" CHECK (recording_mode != 'continuous' OR"
|
|
" tracking_mode = 'duration')"
|
|
");"
|
|
|
|
"CREATE TABLE IF NOT EXISTS sessions ("
|
|
" id INTEGER PRIMARY KEY,"
|
|
" session_id TEXT NOT NULL UNIQUE,"
|
|
" started_at TEXT NOT NULL,"
|
|
" ended_at TEXT,"
|
|
" session_type TEXT NOT NULL DEFAULT 'training'"
|
|
" CHECK (session_type IN ('training', 'max_test')),"
|
|
" notes TEXT"
|
|
");"
|
|
|
|
"CREATE TABLE IF NOT EXISTS session_exercises ("
|
|
" id INTEGER PRIMARY KEY,"
|
|
" entry_id TEXT NOT NULL UNIQUE,"
|
|
" session_row_id INTEGER NOT NULL"
|
|
" REFERENCES sessions(id) ON DELETE CASCADE,"
|
|
" exercise_row_id INTEGER NOT NULL"
|
|
" REFERENCES exercises(id) ON DELETE RESTRICT,"
|
|
" recording_mode TEXT NOT NULL DEFAULT 'sets'"
|
|
" CHECK (recording_mode IN ('sets', 'continuous')),"
|
|
" data_fields INTEGER NOT NULL DEFAULT 0"
|
|
" CHECK (data_fields >= 0 AND (data_fields & ~3) = 0),"
|
|
" position INTEGER NOT NULL CHECK (position >= 0),"
|
|
" load_mode TEXT NOT NULL"
|
|
" CHECK (load_mode IN ('none', 'external', 'assistance')),"
|
|
" rest_seconds INTEGER NOT NULL CHECK (rest_seconds >= 0),"
|
|
" target_sets INTEGER CHECK (target_sets > 0),"
|
|
" target_reps INTEGER CHECK (target_reps >= 1),"
|
|
" target_duration_seconds INTEGER"
|
|
" CHECK (target_duration_seconds > 0),"
|
|
" target_weight_kg REAL CHECK (target_weight_kg > 0.0),"
|
|
" equipment_id TEXT,"
|
|
" notes TEXT,"
|
|
" UNIQUE (session_row_id, position),"
|
|
" CHECK ("
|
|
" (recording_mode = 'sets' AND"
|
|
" ("
|
|
" (target_sets IS NULL AND"
|
|
" target_reps IS NULL AND"
|
|
" target_duration_seconds IS NULL) OR"
|
|
" (target_sets IS NOT NULL AND"
|
|
" ((target_reps IS NOT NULL AND"
|
|
" target_duration_seconds IS NULL) OR"
|
|
" (target_reps IS NULL AND"
|
|
" target_duration_seconds IS NOT NULL)))"
|
|
" )) OR"
|
|
" (recording_mode = 'continuous' AND"
|
|
" target_sets IS NULL AND"
|
|
" target_reps IS NULL AND"
|
|
" target_duration_seconds IS NULL AND"
|
|
" load_mode = 'none' AND"
|
|
" rest_seconds = 0 AND"
|
|
" target_weight_kg IS NULL)"
|
|
" ),"
|
|
" CHECK ("
|
|
" (load_mode = 'none' AND target_weight_kg IS NULL) OR"
|
|
" (load_mode IN ('external', 'assistance') AND"
|
|
" target_weight_kg IS NOT NULL)"
|
|
" )"
|
|
");"
|
|
|
|
"CREATE TABLE IF NOT EXISTS performed_sets ("
|
|
" id INTEGER PRIMARY KEY,"
|
|
" session_exercise_row_id INTEGER NOT NULL"
|
|
" REFERENCES session_exercises(id) ON DELETE CASCADE,"
|
|
" position INTEGER NOT NULL CHECK (position >= 0),"
|
|
" reps INTEGER CHECK (reps >= 0),"
|
|
" duration_seconds INTEGER CHECK (duration_seconds > 0),"
|
|
" weight_kg REAL CHECK (weight_kg > 0.0),"
|
|
" UNIQUE (session_exercise_row_id, position),"
|
|
" CHECK ("
|
|
" (reps IS NOT NULL AND duration_seconds IS NULL) OR"
|
|
" (reps IS NULL AND duration_seconds IS NOT NULL)"
|
|
" )"
|
|
");";
|
|
|
|
static const char *const SCHEMA_V7_SQL_B =
|
|
"CREATE TABLE IF NOT EXISTS continuous_activity ("
|
|
" id INTEGER PRIMARY KEY,"
|
|
" session_exercise_row_id INTEGER NOT NULL UNIQUE"
|
|
" REFERENCES session_exercises(id) ON DELETE CASCADE,"
|
|
" duration_seconds INTEGER NOT NULL CHECK (duration_seconds > 0),"
|
|
" speed_kmh REAL CHECK (speed_kmh > 0.0),"
|
|
" distance_km REAL CHECK (distance_km > 0.0)"
|
|
");"
|
|
|
|
"CREATE TABLE IF NOT EXISTS body_observations ("
|
|
" id INTEGER PRIMARY KEY,"
|
|
" observation_id TEXT NOT NULL UNIQUE,"
|
|
" observed_at TEXT NOT NULL,"
|
|
" session_row_id INTEGER UNIQUE"
|
|
" REFERENCES sessions(id) ON DELETE CASCADE,"
|
|
" body_weight_kg REAL CHECK (body_weight_kg > 0.0),"
|
|
" neck_cm REAL CHECK (neck_cm > 0.0),"
|
|
" shoulders_cm REAL CHECK (shoulders_cm > 0.0),"
|
|
" chest_cm REAL CHECK (chest_cm > 0.0),"
|
|
" waist_cm REAL CHECK (waist_cm > 0.0),"
|
|
" hips_cm REAL CHECK (hips_cm > 0.0),"
|
|
" left_arm_cm REAL CHECK (left_arm_cm > 0.0),"
|
|
" right_arm_cm REAL CHECK (right_arm_cm > 0.0),"
|
|
" left_forearm_cm REAL CHECK (left_forearm_cm > 0.0),"
|
|
" right_forearm_cm REAL CHECK (right_forearm_cm > 0.0),"
|
|
" left_thigh_cm REAL CHECK (left_thigh_cm > 0.0),"
|
|
" right_thigh_cm REAL CHECK (right_thigh_cm > 0.0),"
|
|
" left_calf_cm REAL CHECK (left_calf_cm > 0.0),"
|
|
" right_calf_cm REAL CHECK (right_calf_cm > 0.0),"
|
|
" notes TEXT,"
|
|
" CHECK ("
|
|
" body_weight_kg IS NOT NULL OR"
|
|
" neck_cm IS NOT NULL OR shoulders_cm IS NOT NULL OR"
|
|
" chest_cm IS NOT NULL OR waist_cm IS NOT NULL OR"
|
|
" hips_cm IS NOT NULL OR left_arm_cm IS NOT NULL OR"
|
|
" right_arm_cm IS NOT NULL OR left_forearm_cm IS NOT NULL OR"
|
|
" right_forearm_cm IS NOT NULL OR left_thigh_cm IS NOT NULL OR"
|
|
" right_thigh_cm IS NOT NULL OR left_calf_cm IS NOT NULL OR"
|
|
" right_calf_cm IS NOT NULL"
|
|
" )"
|
|
");"
|
|
|
|
"PRAGMA user_version = 7;"
|
|
"COMMIT;";
|
|
|
|
static const char *const MIGRATE_V5_TO_V6_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"ALTER TABLE session_exercises ADD COLUMN equipment_id TEXT;"
|
|
"PRAGMA user_version = 6;"
|
|
"COMMIT;";
|
|
|
|
/* WHY: a catalogue exercise can occur twice in one completed session. v7
|
|
* introduces a persistent occurrence ID and removes the invalid uniqueness
|
|
* constraint while retaining primary keys and all dependent measurements. */
|
|
static const char *const MIGRATE_V6_TO_V7_SQL =
|
|
"PRAGMA foreign_keys = OFF;"
|
|
"BEGIN IMMEDIATE;"
|
|
"ALTER TABLE session_exercises RENAME TO session_exercises_v6;"
|
|
"CREATE TABLE session_exercises ("
|
|
"id INTEGER PRIMARY KEY, entry_id TEXT NOT NULL UNIQUE,"
|
|
"session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,"
|
|
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,"
|
|
"recording_mode TEXT NOT NULL, data_fields INTEGER NOT NULL,"
|
|
"position INTEGER NOT NULL, load_mode TEXT NOT NULL, rest_seconds INTEGER NOT NULL,"
|
|
"target_sets INTEGER, target_reps INTEGER, target_duration_seconds INTEGER,"
|
|
"target_weight_kg REAL, equipment_id TEXT, notes TEXT, UNIQUE(session_row_id,position));"
|
|
"INSERT INTO "
|
|
"session_exercises(id,entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,"
|
|
"position,load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,target_weight_"
|
|
"kg,equipment_id,notes) "
|
|
"SELECT id,'sxe_legacy_' || "
|
|
"id,session_row_id,exercise_row_id,recording_mode,data_fields,position,load_mode,rest_seconds,"
|
|
"target_sets,target_reps,target_duration_seconds,target_weight_kg,equipment_id,notes FROM "
|
|
"session_exercises_v6;"
|
|
"ALTER TABLE performed_sets RENAME TO performed_sets_v6;"
|
|
"CREATE TABLE performed_sets (id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL "
|
|
"REFERENCES session_exercises(id) ON DELETE CASCADE,position INTEGER NOT NULL,reps "
|
|
"INTEGER,duration_seconds INTEGER,weight_kg REAL,UNIQUE(session_exercise_row_id,position));"
|
|
"INSERT INTO performed_sets SELECT * FROM performed_sets_v6;"
|
|
"ALTER TABLE continuous_activity RENAME TO continuous_activity_v6;"
|
|
"CREATE TABLE continuous_activity (id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT "
|
|
"NULL UNIQUE REFERENCES session_exercises(id) ON DELETE CASCADE,duration_seconds INTEGER NOT "
|
|
"NULL,speed_kmh REAL,distance_km REAL);"
|
|
"INSERT INTO continuous_activity SELECT * FROM continuous_activity_v6;"
|
|
"DROP TABLE performed_sets_v6;DROP TABLE continuous_activity_v6;DROP TABLE "
|
|
"session_exercises_v6;"
|
|
"PRAGMA user_version = 7;COMMIT;PRAGMA foreign_keys = ON;";
|
|
|
|
/* CONTRACT: v8 stores only user-created equipment definitions. Canonical
|
|
* equipment remains generated from the frozen manifest; historic IDs are not
|
|
* backfilled because an occurrence reference has no trustworthy metadata. */
|
|
static const char *const MIGRATE_V7_TO_V8_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE custom_equipment ("
|
|
"equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,"
|
|
"label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,"
|
|
"load_semantics TEXT NOT NULL CHECK(load_semantics IN ('none','external','assistance'))"
|
|
");PRAGMA user_version = 8;COMMIT;";
|
|
|
|
/*
|
|
* WHY: a measured maximum is not a performed set with invented cardinality.
|
|
* CONTRACT: conversion is limited to the only legacy shape whose successful
|
|
* result is unambiguous: one max_test set, one rep, positive weight. Multiple
|
|
* attempts and every other shape remain in performed_sets without data loss.
|
|
*/
|
|
static const char *const MIGRATE_V8_TO_V9_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE max_results ("
|
|
"session_exercise_row_id INTEGER PRIMARY KEY "
|
|
"REFERENCES session_exercises(id) ON DELETE CASCADE,"
|
|
"max_weight_kg REAL NOT NULL CHECK(max_weight_kg > 0.0)"
|
|
");"
|
|
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) "
|
|
"SELECT se.id,ps.weight_kg FROM session_exercises se "
|
|
"JOIN sessions s ON s.id=se.session_row_id "
|
|
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id "
|
|
"WHERE s.session_type='max_test' AND se.recording_mode='sets' "
|
|
"AND ps.reps=1 AND ps.duration_seconds IS NULL "
|
|
"AND ps.weight_kg>0.0 AND "
|
|
"(SELECT COUNT(*) FROM performed_sets all_ps "
|
|
" WHERE all_ps.session_exercise_row_id=se.id)=1;"
|
|
"DELETE FROM performed_sets WHERE session_exercise_row_id IN "
|
|
"(SELECT session_exercise_row_id FROM max_results);"
|
|
"PRAGMA user_version = 9;COMMIT;";
|
|
|
|
/*
|
|
* WHY: actual load absence and an explicit zero are distinct observations.
|
|
* CONTRACT: v10 changes only performed_sets.weight_kg from strictly positive
|
|
* to nonnegative. The explicit projection preserves every row ID, owning
|
|
* occurrence, position, metric value and NULL/positive load byte-for-byte.
|
|
*/
|
|
static const char *const MIGRATE_V9_TO_V10_SQL =
|
|
"PRAGMA foreign_keys = OFF;"
|
|
"BEGIN IMMEDIATE;"
|
|
"ALTER TABLE performed_sets RENAME TO performed_sets_v9;"
|
|
"CREATE TABLE performed_sets ("
|
|
"id INTEGER PRIMARY KEY,"
|
|
"session_exercise_row_id INTEGER NOT NULL "
|
|
"REFERENCES session_exercises(id) ON DELETE CASCADE,"
|
|
"position INTEGER NOT NULL CHECK(position >= 0),"
|
|
"reps INTEGER CHECK(reps >= 0),"
|
|
"duration_seconds INTEGER CHECK(duration_seconds > 0),"
|
|
"weight_kg REAL CHECK(weight_kg >= 0.0),"
|
|
"UNIQUE(session_exercise_row_id,position),"
|
|
"CHECK((reps IS NOT NULL AND duration_seconds IS NULL) OR "
|
|
"(reps IS NULL AND duration_seconds IS NOT NULL))"
|
|
");"
|
|
"INSERT INTO performed_sets("
|
|
"id,session_exercise_row_id,position,reps,duration_seconds,weight_kg"
|
|
") SELECT id,session_exercise_row_id,position,reps,duration_seconds,weight_kg "
|
|
"FROM performed_sets_v9;"
|
|
"DROP TABLE performed_sets_v9;"
|
|
"PRAGMA user_version = 10;"
|
|
"COMMIT;"
|
|
"PRAGMA foreign_keys = ON;";
|
|
|
|
/*
|
|
* WHY: one text column cannot preserve a primary plus multiple secondary
|
|
* zones, and parent groups are derivable catalogue metadata.
|
|
* CONTRACT: v11 is additive and touches no exercise/session/history identity.
|
|
* INVARIANT: the primary key forbids duplicate roles for one zone and the
|
|
* partial unique index permits at most one primary relation per exercise.
|
|
*/
|
|
static const char *const CREATE_BODY_ZONE_RELATIONS_SQL =
|
|
"CREATE TABLE exercise_body_zones("
|
|
"exercise_row_id INTEGER NOT NULL "
|
|
"REFERENCES exercises(id) ON DELETE CASCADE,"
|
|
"zone_id TEXT NOT NULL,"
|
|
"role TEXT NOT NULL CHECK(role IN('primary','secondary')),"
|
|
"PRIMARY KEY(exercise_row_id,zone_id)"
|
|
");"
|
|
"CREATE UNIQUE INDEX exercise_body_zones_one_primary "
|
|
"ON exercise_body_zones(exercise_row_id) WHERE role='primary';"
|
|
"CREATE TABLE exercise_body_zone_sync("
|
|
"exercise_row_id INTEGER PRIMARY KEY "
|
|
"REFERENCES exercises(id) ON DELETE CASCADE,"
|
|
"synced_state TEXT NOT NULL"
|
|
");";
|
|
|
|
/* WHY: deleted duplicate catalogue rows still arrive from offline peers.
|
|
* CONTRACT: aliases are a separate identity artifact, never an overload of a
|
|
* frozen mobile-export field. INVARIANT: canonical IDs always name a live
|
|
* exercise row and mappings are stored collapsed, so cycles/chains cannot be
|
|
* represented by valid database writes. */
|
|
static const char *const MIGRATE_V11_TO_V12_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE exercise_aliases("
|
|
"source_exercise_id TEXT PRIMARY KEY,"
|
|
"canonical_exercise_id TEXT NOT NULL "
|
|
"REFERENCES exercises(exercise_id) ON DELETE RESTRICT,"
|
|
"CHECK(source_exercise_id<>canonical_exercise_id)"
|
|
");"
|
|
"CREATE INDEX exercise_aliases_canonical "
|
|
"ON exercise_aliases(canonical_exercise_id);"
|
|
"PRAGMA user_version = 12;COMMIT;";
|
|
|
|
/* WHY: the selectable identity is now the concrete machine exercise; legacy
|
|
* equipment remains occurrence provenance for V3/old-peer compatibility.
|
|
* CONTRACT: v13 is additive, creates only the five frozen UUID identities and
|
|
* repoints only the approved entry/exercise/equipment triples. INVARIANT:
|
|
* session/entry IDs and every child fact remain untouched. */
|
|
static const char *const MIGRATE_V12_TO_V13_SQL_A =
|
|
"BEGIN IMMEDIATE;"
|
|
"ALTER TABLE exercises ADD COLUMN load_semantics TEXT CHECK(load_semantics IN "
|
|
"('none','external','assistance','bodyweight','cardio'));"
|
|
"ALTER TABLE exercises ADD COLUMN machine_variant TEXT;"
|
|
"ALTER TABLE exercises ADD COLUMN machine_provenance TEXT;"
|
|
"ALTER TABLE exercises ADD COLUMN scientific_profile_id TEXT;"
|
|
"ALTER TABLE exercises ADD COLUMN science_state TEXT NOT NULL DEFAULT 'unresolved' "
|
|
"CHECK(science_state IN ('resolved','unresolved'));"
|
|
"ALTER TABLE exercises ADD COLUMN legacy_equipment_id TEXT;"
|
|
"UPDATE exercises SET name='Hip Abduction',normalized_name='hip "
|
|
"abduction',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_"
|
|
"hip_abduction_v1',science_state='resolved',legacy_equipment_id='hip_abduction' WHERE "
|
|
"exercise_id='ex_7e7cf906-2214-4066-bcb7-c16382d83b3b' AND name='Abduction de hanche assise';"
|
|
"UPDATE exercises SET name='Leg Extension',normalized_name='leg "
|
|
"extension',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_"
|
|
"leg_extension_v1',science_state='resolved',legacy_equipment_id='leg_extension' WHERE "
|
|
"exercise_id='ex_1872246a-39ae-44dc-b58d-f87e90ca49ab' AND name='Extension de genou assise';"
|
|
"UPDATE exercises SET name='Back Extension',normalized_name='back "
|
|
"extension',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_"
|
|
"back_extension_v1',science_state='resolved',legacy_equipment_id='back_extension' WHERE "
|
|
"exercise_id='ex_474ec393-3efa-4aaa-8e08-1a0245ed7835' AND name='Extension du tronc';"
|
|
"UPDATE exercises SET name='Arm Curl',normalized_name='arm "
|
|
"curl',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_arm_"
|
|
"curl_v1',science_state='resolved',legacy_equipment_id='arm_curl' WHERE "
|
|
"exercise_id='ex_ec619fc2-4685-4044-873c-86764bd4a0fe' AND name='Flexion de coude à la "
|
|
"machine';"
|
|
"UPDATE exercises SET name='Prone Leg Curl',normalized_name='prone leg "
|
|
"curl',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_"
|
|
"prone_leg_curl_v1',science_state='resolved',legacy_equipment_id='prone_leg_curl' WHERE "
|
|
"exercise_id='ex_d7398d9f-d928-4d2e-94e9-74e201da55c5' AND name='Flexion de genou couchée';"
|
|
"UPDATE exercises SET name='Rotary Torso',normalized_name='rotary "
|
|
"torso',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_"
|
|
"rotary_torso_v1',science_state='resolved',legacy_equipment_id='rotary_torso' WHERE "
|
|
"exercise_id='ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0' AND name='Rotation du tronc à la "
|
|
"machine';";
|
|
|
|
static const char *const MIGRATE_V12_TO_V13_SQL_B =
|
|
"UPDATE exercises SET name='Seated Row',normalized_name='seated "
|
|
"row',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_"
|
|
"seated_row_v1',science_state='resolved',legacy_equipment_id='seated_row' WHERE "
|
|
"exercise_id='ex_33f79331-871c-4eed-babe-346e53a99070' AND name='Tirage horizontal assis';"
|
|
"UPDATE exercises SET name='Diverging Seated Row',normalized_name='diverging seated "
|
|
"row',load_semantics='external',machine_variant='diverging',scientific_profile_id='sp_seated_"
|
|
"row_v1',science_state='resolved',legacy_equipment_id='diverging_seated_row' WHERE "
|
|
"exercise_id='ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de' AND name='Tirage horizontal divergent "
|
|
"assis';"
|
|
"UPDATE exercises SET name='Diverging Lat Pulldown',normalized_name='diverging lat "
|
|
"pulldown',load_semantics='external',machine_variant='diverging',scientific_profile_id='sp_"
|
|
"vertical_pull_v1',science_state='resolved',legacy_equipment_id='diverging_lat_pulldown' WHERE "
|
|
"exercise_id='ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6' AND name='Tirage vertical divergent';"
|
|
"UPDATE exercises SET name='Lat Pull',normalized_name='lat "
|
|
"pull',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_"
|
|
"vertical_pull_v1',science_state='resolved',legacy_equipment_id='lat_pull' WHERE "
|
|
"exercise_id='ex_a72fa713-4b0e-431d-95e2-42d95beb77b1' AND name='Tirage vertical à la poulie';"
|
|
"UPDATE exercises SET name='Converging Shoulder Press',normalized_name='converging shoulder "
|
|
"press',load_semantics='external',machine_variant='converging',scientific_profile_id='sp_"
|
|
"shoulder_press_v1',science_state='resolved',legacy_equipment_id='converging_shoulder_press' "
|
|
"WHERE exercise_id='ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b' AND name='Développé épaules "
|
|
"convergent';"
|
|
"UPDATE exercises SET name='Seated Leg Curl',normalized_name='seated leg "
|
|
"curl',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_"
|
|
"seated_leg_curl_v1',science_state='resolved',legacy_equipment_id='seated_leg_curl' WHERE "
|
|
"exercise_id='ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163' AND name='Flexion de genou assise';"
|
|
"UPDATE exercises SET name='Leg Press',normalized_name='leg "
|
|
"press',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_leg_"
|
|
"press_v1',science_state='resolved',legacy_equipment_id='leg_press' WHERE "
|
|
"exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde' AND name IN ('Presse à cuisses','Leg "
|
|
"press');"
|
|
"UPDATE exercises SET name='Rear Delt',normalized_name='rear "
|
|
"delt',load_semantics='external',machine_variant='rear_delt',scientific_profile_id='sp_rear_"
|
|
"delt_v1',science_state='resolved',legacy_equipment_id='rear_delt_pec_fly' WHERE "
|
|
"exercise_id='ex_4cd2433e-80b1-478a-b8df-73fc6ef80962' AND name='Écarté inversé à la machine';"
|
|
"INSERT INTO "
|
|
"exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields,load_"
|
|
"semantics,machine_variant,scientific_profile_id,science_state,legacy_equipment_id) VALUES"
|
|
"('ex_0e26c06f-a458-40a4-be20-4ed219ede30d','Plate Loaded Leg Press','plate loaded leg "
|
|
"press','reps','sets',0,'external','plate_loaded','sp_leg_press_v1','resolved','plate_loaded_"
|
|
"leg_press'),"
|
|
"('ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2','Treadmill','treadmill','duration','continuous',3,'"
|
|
"cardio','treadmill',NULL,'unresolved','treadmill'),"
|
|
"('ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee','Pec Fly','pec "
|
|
"fly','reps','sets',0,'external','pec_fly','sp_pec_fly_v1','resolved','rear_delt_pec_fly'),"
|
|
"('ex_58b8dfbc-92b2-4783-a449-9947a42480b8','Chin Assist','chin "
|
|
"assist','reps','sets',0,'assistance','assisted','sp_assisted_chin_v1','resolved','assisted_"
|
|
"dip_chin_machine'),"
|
|
"('ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf','Dip Assist','dip "
|
|
"assist','reps','sets',0,'assistance','assisted','sp_assisted_dip_v1','resolved','assisted_dip_"
|
|
"chin_machine');";
|
|
|
|
static const char *const MIGRATE_V12_TO_V13_SQL_C =
|
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT n.id,z.zone_id,z.role "
|
|
"FROM exercises n JOIN exercises o ON o.exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde' "
|
|
"JOIN exercise_body_zones z ON z.exercise_row_id=o.id WHERE "
|
|
"n.exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d';"
|
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'chest','primary' "
|
|
"FROM exercises WHERE exercise_id='ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee';"
|
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT "
|
|
"id,'shoulders','secondary' FROM exercises WHERE "
|
|
"exercise_id='ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee';"
|
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'back','primary' FROM "
|
|
"exercises WHERE exercise_id='ex_58b8dfbc-92b2-4783-a449-9947a42480b8';"
|
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'arms','secondary' "
|
|
"FROM exercises WHERE exercise_id='ex_58b8dfbc-92b2-4783-a449-9947a42480b8';"
|
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'arms','primary' FROM "
|
|
"exercises WHERE exercise_id='ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf';"
|
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'chest','secondary' "
|
|
"FROM exercises WHERE exercise_id='ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf';"
|
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT "
|
|
"id,'shoulders','secondary' FROM exercises WHERE "
|
|
"exercise_id='ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf';"
|
|
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,'thighs|glutes' "
|
|
"FROM exercises WHERE exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d';"
|
|
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,'chest|shoulders' "
|
|
"FROM exercises WHERE exercise_id='ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee';"
|
|
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,'back|arms' FROM "
|
|
"exercises WHERE exercise_id='ex_58b8dfbc-92b2-4783-a449-9947a42480b8';"
|
|
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT "
|
|
"id,'arms|chest,shoulders' FROM exercises WHERE "
|
|
"exercise_id='ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf';"
|
|
"UPDATE session_exercises SET exercise_row_id=(SELECT id FROM exercises WHERE "
|
|
"exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d') WHERE entry_id='sxe_draft_legacy_7' "
|
|
"AND equipment_id='plate_loaded_leg_press' AND exercise_row_id=(SELECT id FROM exercises WHERE "
|
|
"exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde');"
|
|
"UPDATE session_exercises SET exercise_row_id=(SELECT id FROM exercises WHERE "
|
|
"exercise_id='ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2') WHERE entry_id IN "
|
|
"('sxe_093c1331-beaa-4b69-91b3-240292709be6','sxe_f25142c8-455e-4346-9bfc-31d0989e275d','sxe_"
|
|
"f2242691-ba6c-48a0-b938-a1f744375d75','sxe_9f8882f4-069e-49d5-8216-19d16b467e4a') AND "
|
|
"equipment_id='treadmill' AND exercise_row_id=(SELECT id FROM exercises WHERE "
|
|
"exercise_id='ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d');"
|
|
"UPDATE exercises SET "
|
|
"load_semantics=NULL,machine_variant=NULL,machine_provenance=NULL,scientific_profile_id=NULL,"
|
|
"science_state='unresolved',legacy_equipment_id=NULL WHERE "
|
|
"exercise_id='ex_617007f9-7420-4408-91b9-8ffb77900f13';"
|
|
"PRAGMA user_version=13;COMMIT;";
|
|
|
|
/* WHY: subjective observations have their own immutable chronology and must
|
|
* never be encoded as performed sets, notes, or inferred exercise metadata.
|
|
* CONTRACT: v14 is additive; exercise feedback belongs to one durable
|
|
* occurrence and follow-up belongs only to its session. Text byte bounds are
|
|
* enforced by import/application APIs so SQLite never silently truncates.
|
|
* INVARIANT: every v13 row and identity remains byte-for-byte untouched. */
|
|
static const char *const MIGRATE_V13_TO_V14_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE exercise_feedback("
|
|
"id INTEGER PRIMARY KEY,feedback_id TEXT NOT NULL UNIQUE,"
|
|
"session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE,"
|
|
"observed_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));"
|
|
"CREATE INDEX exercise_feedback_timeline ON "
|
|
"exercise_feedback(session_exercise_row_id,observed_at,feedback_id);"
|
|
"CREATE TABLE session_followups("
|
|
"id INTEGER PRIMARY KEY,followup_id TEXT NOT NULL UNIQUE,"
|
|
"session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,"
|
|
"observed_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));"
|
|
"CREATE INDEX session_followups_timeline ON "
|
|
"session_followups(session_row_id,observed_at,followup_id);"
|
|
"PRAGMA user_version=14;COMMIT;";
|
|
|
|
/* WHY: wording corrections must not overwrite the original wording.
|
|
* CONTRACT: v14 root raw_text remains a transactionally maintained current
|
|
* cache; immutable revisions are the history source of truth. INVARIANT:
|
|
* fr0_<root-id> is deterministic across peers and migration/V1 import is
|
|
* idempotent. */
|
|
static const char *const MIGRATE_V14_TO_V15_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE exercise_feedback_revisions("
|
|
"revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL REFERENCES "
|
|
"exercise_feedback(feedback_id) ON DELETE CASCADE,"
|
|
"created_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));"
|
|
"CREATE INDEX exercise_feedback_revisions_current ON "
|
|
"exercise_feedback_revisions(feedback_id,created_at,revision_id);"
|
|
"CREATE TABLE session_followup_revisions("
|
|
"revision_id TEXT PRIMARY KEY,followup_id TEXT NOT NULL REFERENCES "
|
|
"session_followups(followup_id) ON DELETE CASCADE,"
|
|
"created_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));"
|
|
"CREATE INDEX session_followup_revisions_current ON "
|
|
"session_followup_revisions(followup_id,created_at,revision_id);"
|
|
"INSERT INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) "
|
|
"SELECT 'fr0_'||feedback_id,feedback_id,observed_at,raw_text FROM exercise_feedback;"
|
|
"INSERT INTO session_followup_revisions(revision_id,followup_id,created_at,raw_text) "
|
|
"SELECT 'fr0_'||followup_id,followup_id,observed_at,raw_text FROM session_followups;"
|
|
"PRAGMA user_version=15;COMMIT;";
|
|
|
|
static TrainlogStatus
|
|
read_single_int_pragma(TrainlogDatabase *database, const char *sql, int *output);
|
|
static TrainlogStatus execute_sql(TrainlogDatabase *database, const char *sql);
|
|
|
|
/* WHY: catalogue profiles describe future capture and may change, while an
|
|
* occurrence's performed values must retain their original unit forever.
|
|
* CONTRACT: v15 is the sole source schema; backfill copies the linked exercise
|
|
* mode exactly once before any later profile edit. INVARIANT: every existing
|
|
* row/child/stable identity remains untouched and version 16 is published only
|
|
* after all snapshots are valid and foreign keys remain satisfied. */
|
|
static TrainlogStatus migrate_v15_to_v16(TrainlogDatabase *database) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int version = -1;
|
|
int invalid = -1;
|
|
int source_count = -1;
|
|
int migrated_count = -1;
|
|
int rc;
|
|
if (read_single_int_pragma(database, "PRAGMA user_version;", &version) != TRAINLOG_STATUS_OK ||
|
|
version != 15) {
|
|
return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
|
}
|
|
if (read_single_int_pragma(database,
|
|
"SELECT COUNT(*) FROM session_exercises;",
|
|
&source_count) != TRAINLOG_STATUS_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
/* SQLite cannot add a NOT NULL column without a lasting default. Rebuild
|
|
* the parent table with FK enforcement temporarily disabled so child rows
|
|
* are neither cascaded nor retargeted to a temporary table name. The
|
|
* transaction plus the explicit foreign_key_check preserves atomicity. */
|
|
if (execute_sql(database, "PRAGMA foreign_keys=OFF;") != TRAINLOG_STATUS_OK ||
|
|
execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK ||
|
|
execute_sql(
|
|
database,
|
|
"CREATE TABLE session_exercises_v16("
|
|
"id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,"
|
|
"session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,"
|
|
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,"
|
|
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,"
|
|
"position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT NULL,"
|
|
"target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,"
|
|
"target_weight_kg REAL,equipment_id TEXT,notes TEXT,"
|
|
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration')),"
|
|
"UNIQUE(session_row_id,position));"
|
|
"INSERT INTO session_exercises_v16("
|
|
"id,entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position,"
|
|
"load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,"
|
|
"target_weight_kg,equipment_id,notes,tracking_mode) "
|
|
"SELECT se.id,se.entry_id,se.session_row_id,se.exercise_row_id,se.recording_mode,"
|
|
"se.data_fields,se.position,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,"
|
|
"se.target_duration_seconds,se.target_weight_kg,se.equipment_id,se.notes,e.tracking_"
|
|
"mode "
|
|
"FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id;"
|
|
"DROP TABLE session_exercises;"
|
|
"ALTER TABLE session_exercises_v16 RENAME TO session_exercises;") !=
|
|
TRAINLOG_STATUS_OK) {
|
|
goto rollback;
|
|
}
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"SELECT COUNT(*) FROM session_exercises se LEFT JOIN exercises e "
|
|
"ON e.id=se.exercise_row_id WHERE e.id IS NULL OR "
|
|
"se.tracking_mode NOT IN('reps','duration') OR "
|
|
"se.tracking_mode<>e.tracking_mode;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) {
|
|
goto rollback;
|
|
}
|
|
invalid = sqlite3_column_int(statement, 0);
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
statement = NULL;
|
|
goto rollback;
|
|
}
|
|
statement = NULL;
|
|
if (read_single_int_pragma(database,
|
|
"SELECT COUNT(*) FROM session_exercises;",
|
|
&migrated_count) != TRAINLOG_STATUS_OK ||
|
|
migrated_count != source_count || invalid != 0 ||
|
|
read_single_int_pragma(database,
|
|
"SELECT COUNT(*) FROM pragma_foreign_key_check;",
|
|
&invalid) != TRAINLOG_STATUS_OK ||
|
|
invalid != 0 ||
|
|
execute_sql(database, "PRAGMA user_version=16;COMMIT;") != TRAINLOG_STATUS_OK) {
|
|
goto rollback;
|
|
}
|
|
if (execute_sql(database, "PRAGMA foreign_keys=ON;") != TRAINLOG_STATUS_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
rollback:
|
|
if (statement != NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
}
|
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
|
(void)sqlite3_exec(database->connection, "PRAGMA foreign_keys=ON;", NULL, NULL, NULL);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
/* WHY: a current catalogue profile is mutable, but timestamps cannot establish
|
|
* ancestry across offline peers. CONTRACT: every v16 exercise starts at the
|
|
* same explicit legacy root; the first exchanged snapshot may therefore repair
|
|
* a pre-contract difference. Later edits store a deterministic child and its
|
|
* bounded revision chain, so transitive ancestor and sibling cases are unambiguous.
|
|
* INVARIANT: this migration changes no exercise or occurrence value. */
|
|
static const char *const MIGRATE_V16_TO_V17_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE exercise_profile_state("
|
|
"exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE,"
|
|
"revision_id TEXT NOT NULL,parent_revision_id TEXT,"
|
|
"legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1)));"
|
|
"INSERT INTO "
|
|
"exercise_profile_state(exercise_row_id,revision_id,parent_revision_id,legacy_seed) "
|
|
"SELECT id,'pr_legacy_v1',NULL,1 FROM exercises;"
|
|
"CREATE TABLE exercise_profile_revisions("
|
|
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,"
|
|
"revision_id TEXT NOT NULL,parent_revision_id TEXT,recording_mode TEXT NOT NULL,"
|
|
"tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,legacy_seed INTEGER NOT NULL "
|
|
"CHECK(legacy_seed IN(0,1)),"
|
|
"PRIMARY KEY(exercise_row_id,revision_id));"
|
|
"INSERT INTO exercise_profile_revisions SELECT "
|
|
"id,'pr_legacy_v1',NULL,recording_mode,tracking_mode,data_fields,1 FROM exercises;"
|
|
"CREATE TRIGGER exercise_profile_state_insert AFTER INSERT ON exercises BEGIN "
|
|
"INSERT INTO "
|
|
"exercise_profile_state(exercise_row_id,revision_id,parent_revision_id,legacy_seed) "
|
|
"VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,"
|
|
"NEW.data_fields),'pr_legacy_v1',0);"
|
|
"INSERT INTO exercise_profile_revisions "
|
|
"VALUES(NEW.id,'pr_legacy_v1',NULL,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,1);"
|
|
"INSERT INTO exercise_profile_revisions "
|
|
"VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,"
|
|
"NEW.data_fields),'pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0);END;"
|
|
"CREATE TRIGGER exercise_profile_state_update AFTER UPDATE OF "
|
|
"recording_mode,tracking_mode,data_fields ON exercises "
|
|
"WHEN (OLD.recording_mode<>NEW.recording_mode OR OLD.tracking_mode<>NEW.tracking_mode OR "
|
|
"OLD.data_fields<>NEW.data_fields) "
|
|
"AND NOT EXISTS(SELECT 1 FROM exercise_profile_state s JOIN exercise_profile_revisions r ON "
|
|
"r.exercise_row_id=s.exercise_row_id AND r.revision_id=s.revision_id WHERE "
|
|
"s.exercise_row_id=NEW.id AND r.recording_mode=NEW.recording_mode AND "
|
|
"r.tracking_mode=NEW.tracking_mode AND r.data_fields=NEW.data_fields) BEGIN "
|
|
"SELECT CASE WHEN (SELECT COUNT(*) FROM exercise_profile_revisions WHERE "
|
|
"exercise_row_id=NEW.id)>=32 THEN RAISE(ABORT,'profile revision history limit') END;"
|
|
"INSERT OR IGNORE INTO exercise_profile_revisions SELECT "
|
|
"NEW.id,s.revision_id,s.parent_revision_id,OLD.recording_mode,OLD.tracking_mode,OLD.data_"
|
|
"fields,s.legacy_seed FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;"
|
|
"UPDATE exercise_profile_state SET "
|
|
"parent_revision_id=revision_id,revision_id=trainlog_profile_revision(revision_id,NEW."
|
|
"recording_mode,NEW.tracking_mode,NEW.data_fields),legacy_seed=0 WHERE exercise_row_id=NEW.id;"
|
|
"INSERT INTO exercise_profile_revisions SELECT "
|
|
"NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_"
|
|
"fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;"
|
|
"PRAGMA user_version=17;COMMIT;";
|
|
|
|
/* WHY: generated plans are untrusted inbox data and must never masquerade as
|
|
* completed occurrence history. CONTRACT: v18 adds an isolated durable inbox,
|
|
* target-only SETS children, canonical import identity, bounded publication
|
|
* state, and post-commit Drive archive state. INVARIANT: no existing domain
|
|
* table or frozen exchange column is changed. */
|
|
static const char *const MIGRATE_V17_TO_V18_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE ai_session_drafts("
|
|
"id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,created_at TEXT NOT NULL,"
|
|
"planned_for TEXT,session_type TEXT NOT NULL CHECK(session_type='training'),"
|
|
"title TEXT,notes TEXT,archive_status TEXT NOT NULL DEFAULT 'pending' "
|
|
"CHECK(archive_status IN('pending','archived','failed')),"
|
|
"archived_at TEXT,archive_error TEXT,published_at TEXT);"
|
|
"CREATE TABLE ai_session_draft_entries("
|
|
"id INTEGER PRIMARY KEY,draft_row_id INTEGER NOT NULL REFERENCES ai_session_drafts(id) ON "
|
|
"DELETE CASCADE,"
|
|
"entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL CHECK(position>=0),exercise_row_id "
|
|
"INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,"
|
|
"source_exercise_id TEXT NOT NULL,recording_mode TEXT NOT NULL CHECK(recording_mode='sets'),"
|
|
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration')),data_fields INTEGER "
|
|
"NOT NULL,"
|
|
"equipment_id TEXT,load_mode TEXT NOT NULL CHECK(load_mode IN('none','external')),"
|
|
"target_sets INTEGER NOT NULL CHECK(target_sets BETWEEN 1 AND 99),"
|
|
"target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,"
|
|
"rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400),"
|
|
"UNIQUE(draft_row_id,position));"
|
|
"CREATE INDEX ai_session_draft_entries_exercise ON ai_session_draft_entries(exercise_row_id);"
|
|
"CREATE TABLE ai_session_draft_imports("
|
|
"draft_row_id INTEGER PRIMARY KEY,"
|
|
"draft_id TEXT NOT NULL UNIQUE,payload_sha256 TEXT NOT NULL UNIQUE,imported_at TEXT NOT NULL);"
|
|
"CREATE TRIGGER ai_session_draft_import_identity_guard BEFORE DELETE ON ai_session_drafts "
|
|
"WHEN EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) "
|
|
"BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;"
|
|
"PRAGMA user_version=18;COMMIT;";
|
|
|
|
/* WHY: V3 cannot carry lifecycle-owned notes, observation parents, or
|
|
* resumable execution drafts. CONTRACT: v19 is additive and provides only the
|
|
* durable state required by the explicitly selected V4/lifecycle codecs.
|
|
* INVARIANT: completed history, AI proposals, feedback, aliases, and every
|
|
* existing stable identity remain untouched; NULL legacy notes keep unknown
|
|
* ancestry and no timestamp or causal parent is fabricated. */
|
|
static const char *const MIGRATE_V18_TO_V19_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE sync_note_revisions("
|
|
"owner_kind TEXT NOT NULL CHECK(owner_kind IN('session','occurrence','observation')),"
|
|
"owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,note TEXT,"
|
|
"CHECK(note IS NULL OR length(CAST(note AS BLOB))<=4096),"
|
|
"PRIMARY KEY(owner_kind,owner_id,revision_id));"
|
|
"CREATE TABLE sync_note_state("
|
|
"owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,"
|
|
"PRIMARY KEY(owner_kind,owner_id),"
|
|
"FOREIGN KEY(owner_kind,owner_id,revision_id) REFERENCES "
|
|
"sync_note_revisions(owner_kind,owner_id,revision_id));"
|
|
"CREATE TABLE execution_drafts("
|
|
"session_id TEXT PRIMARY KEY,session_type TEXT NOT NULL CHECK(session_type "
|
|
"IN('training','max_test')),"
|
|
"source_session_id TEXT,started_at TEXT,revision_id TEXT NOT NULL,parent_revision_id TEXT,"
|
|
"state TEXT NOT NULL CHECK(state IN('active','pending')),payload_json TEXT NOT NULL);"
|
|
"CREATE TABLE execution_draft_revisions("
|
|
"session_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,payload_json TEXT "
|
|
"NOT NULL,"
|
|
"PRIMARY KEY(session_id,revision_id));"
|
|
"CREATE TABLE execution_draft_finalizations("
|
|
"session_id TEXT PRIMARY KEY,final_revision_id TEXT NOT NULL,finalized_at TEXT NOT NULL);"
|
|
"PRAGMA user_version=19;COMMIT;";
|
|
|
|
/* WHY: offline peers can replay live snapshots after an intentional deletion.
|
|
* CONTRACT: v20 records immutable causal deletion operations separately from
|
|
* future publication generations. INVARIANT: no legacy row is marked deleted
|
|
* and no ancestry, peer acknowledgement, or emission context is fabricated. */
|
|
static const char *const MIGRATE_V19_TO_V20_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE sync_causal_operations("
|
|
"operation_id TEXT PRIMARY KEY,target_kind TEXT NOT NULL,target_id TEXT NOT NULL,"
|
|
"creator_id TEXT NOT NULL,predecessor_revision_id TEXT NOT NULL,"
|
|
"created_at TEXT NOT NULL,payload_sha256 TEXT NOT NULL,publication_context TEXT,"
|
|
"CHECK(length(operation_id) BETWEEN 1 AND 128),"
|
|
"CHECK(length(target_id) BETWEEN 1 AND 512),"
|
|
"CHECK(length(creator_id) BETWEEN 1 AND 128),"
|
|
"CHECK(length(predecessor_revision_id) BETWEEN 1 AND 128),"
|
|
"CHECK(length(payload_sha256)=64),"
|
|
"UNIQUE(target_kind,target_id,operation_id));"
|
|
"CREATE TABLE sync_causal_state("
|
|
"target_kind TEXT NOT NULL,target_id TEXT NOT NULL,current_revision_id TEXT NOT NULL,"
|
|
"deleted INTEGER NOT NULL CHECK(deleted IN(0,1)),operation_id TEXT,"
|
|
"PRIMARY KEY(target_kind,target_id),"
|
|
"FOREIGN KEY(operation_id) REFERENCES sync_causal_operations(operation_id));"
|
|
"CREATE INDEX sync_causal_operations_target ON sync_causal_operations(target_kind,target_id);"
|
|
"PRAGMA user_version=20;COMMIT;";
|
|
|
|
/* WHY: publication and durable consumption are distinct from immutable domain
|
|
* operations. CONTRACT: v21 stores peer/generation/manifest/ACK identity and a
|
|
* separate operation-to-generation ledger. INVARIANT: migration invents no
|
|
* generation, acknowledgement, consumption, ancestry, or causal operation. */
|
|
static const char *const MIGRATE_V20_TO_V21_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE sync_peer_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1),peer_id TEXT "
|
|
"NOT NULL UNIQUE,kind TEXT NOT NULL CHECK(kind IN('desktop','android')));"
|
|
"CREATE TABLE sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT "
|
|
"NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,producer_kind TEXT NOT "
|
|
"NULL,generated_at TEXT NOT NULL,parent_generation_id TEXT,manifest_sha256 TEXT NOT "
|
|
"NULL,status TEXT NOT NULL CHECK(status "
|
|
"IN('captured','published','waiting_acknowledgement','acknowledged','rejected')),manifest_json "
|
|
"TEXT NOT NULL,staging_path TEXT NOT NULL,acknowledged_at TEXT);"
|
|
"CREATE TABLE sync_generation_artifacts(generation_id TEXT NOT NULL REFERENCES "
|
|
"sync_generations(generation_id) ON DELETE CASCADE,logical_name TEXT NOT NULL,format TEXT NOT "
|
|
"NULL,version INTEGER NOT NULL,filename TEXT NOT NULL,size_bytes INTEGER NOT NULL,sha256 TEXT "
|
|
"NOT NULL,required INTEGER NOT NULL CHECK(required IN(0,1)),PRIMARY "
|
|
"KEY(generation_id,logical_name),UNIQUE(generation_id,filename));"
|
|
"CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT NOT "
|
|
"NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,parent_generation_id "
|
|
"TEXT,manifest_sha256 TEXT NOT NULL,consumed_at TEXT NOT NULL,result TEXT NOT NULL "
|
|
"CHECK(result IN('consumed','rejected')),durability TEXT NOT NULL,diagnostic TEXT NOT "
|
|
"NULL,ack_json TEXT NOT NULL,UNIQUE(producer_peer_id,generation_id));"
|
|
"CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT NOT NULL,run_id "
|
|
"TEXT NOT NULL,producer_peer_id TEXT NOT NULL,consumer_peer_id TEXT NOT NULL,manifest_sha256 "
|
|
"TEXT NOT NULL,result TEXT NOT NULL CHECK(result IN('consumed','rejected')),durability TEXT "
|
|
"NOT NULL,created_at TEXT NOT NULL,diagnostic TEXT NOT NULL,payload_sha256 TEXT NOT NULL);"
|
|
"CREATE TABLE sync_causal_publications(operation_id TEXT NOT NULL REFERENCES "
|
|
"sync_causal_operations(operation_id),generation_id TEXT NOT NULL,first_emission INTEGER NOT "
|
|
"NULL CHECK(first_emission IN(0,1)),PRIMARY KEY(operation_id,generation_id));"
|
|
"PRAGMA user_version=21;COMMIT;";
|
|
|
|
/* WHY: acknowledged payload copies cannot consume active admission forever,
|
|
* but generation identity, lineage and ACK evidence must remain permanent.
|
|
* CONTRACT: v22 adds an append-only archive ledger; it neither fabricates an
|
|
* ACK nor changes a generation status. INVARIANT: an archive row is valid only
|
|
* after production code has durably copied and verified the complete payload. */
|
|
static const char *const MIGRATE_V21_TO_V22_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE sync_generation_archives("
|
|
"generation_id TEXT PRIMARY KEY REFERENCES sync_generations(generation_id),"
|
|
"archive_path TEXT NOT NULL UNIQUE,manifest_sha256 TEXT NOT NULL,"
|
|
"archive_sha256 TEXT NOT NULL,archived_at TEXT NOT NULL,"
|
|
"audit_json TEXT NOT NULL);"
|
|
"PRAGMA user_version=22;COMMIT;";
|
|
|
|
/* WHY: a Web-authored preparation is neither an imported AI proposal nor an
|
|
* execution draft. CONTRACT: v23 stores immutable preparation revisions,
|
|
* stable ordered occurrences, durable request replay, and delivery identity
|
|
* without changing completed history or frozen exchange formats. INVARIANT:
|
|
* migration invents no preparation, execution, delivery, or acknowledgement. */
|
|
static const char *const MIGRATE_V22_TO_V23_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE session_preparations("
|
|
"preparation_id TEXT PRIMARY KEY,current_revision_id TEXT NOT NULL,created_at TEXT NOT NULL,"
|
|
"updated_at TEXT NOT NULL,editing_state TEXT NOT NULL CHECK(editing_state IN('draft','ready')),"
|
|
"delivery_state TEXT NOT NULL DEFAULT 'local' CHECK(delivery_state "
|
|
"IN('local','pending','acknowledged','remote_unknown','cancelled')),"
|
|
"source_proposal_id TEXT,source_payload_sha256 TEXT);"
|
|
"CREATE TABLE session_preparation_revisions("
|
|
"revision_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL REFERENCES "
|
|
"session_preparations(preparation_id) ON DELETE RESTRICT,"
|
|
"parent_revision_id TEXT,title TEXT NOT NULL,session_type TEXT NOT NULL CHECK(session_type "
|
|
"IN('training','max_test')),"
|
|
"planned_for TEXT,notes TEXT,created_at TEXT NOT NULL,"
|
|
"CHECK(length(CAST(title AS BLOB))<=200),CHECK(notes IS NULL OR length(CAST(notes AS "
|
|
"BLOB))<=4000),"
|
|
"UNIQUE(preparation_id,revision_id));"
|
|
"CREATE TABLE session_preparation_entries("
|
|
"revision_id TEXT NOT NULL REFERENCES session_preparation_revisions(revision_id) ON DELETE "
|
|
"RESTRICT,"
|
|
"entry_id TEXT NOT NULL,position INTEGER NOT NULL CHECK(position>=0),exercise_id TEXT NOT NULL,"
|
|
"equipment_id TEXT,recording_mode TEXT NOT NULL CHECK(recording_mode IN('sets','continuous')),"
|
|
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration')),data_fields INTEGER "
|
|
"NOT NULL,"
|
|
"load_mode TEXT NOT NULL CHECK(load_mode IN('none','external','assistance')),rest_seconds "
|
|
"INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400),"
|
|
"target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg "
|
|
"REAL,notes TEXT,"
|
|
"PRIMARY KEY(revision_id,entry_id),UNIQUE(revision_id,position),"
|
|
"CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4000));"
|
|
"CREATE INDEX session_preparation_entries_exercise ON session_preparation_entries(exercise_id);"
|
|
"CREATE TABLE session_preparation_requests("
|
|
"request_id TEXT PRIMARY KEY,command TEXT NOT NULL,preparation_id TEXT NOT NULL,revision_id "
|
|
"TEXT NOT NULL,"
|
|
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
|
|
"CREATE TABLE session_preparation_deliveries("
|
|
"delivery_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL REFERENCES "
|
|
"session_preparations(preparation_id),"
|
|
"revision_id TEXT NOT NULL REFERENCES "
|
|
"session_preparation_revisions(revision_id),execution_session_id TEXT NOT NULL UNIQUE,"
|
|
"state TEXT NOT NULL CHECK(state IN('pending','acknowledged','remote_unknown','cancelled')),"
|
|
"created_at TEXT NOT NULL,generation_id TEXT,acknowledged_at TEXT,"
|
|
"UNIQUE(preparation_id,revision_id,delivery_id));"
|
|
"PRAGMA user_version=23;COMMIT;";
|
|
|
|
/* WHY: omitting a delivered preparation from later snapshots cannot retract
|
|
* an already consumed Android object and permits replay resurrection.
|
|
* CONTRACT: v24 records one immutable, exportable withdrawal per preparation
|
|
* while retaining revisions, deliveries, ACKs and execution identities.
|
|
* INVARIANT: migration withdraws nothing and never touches workout facts. */
|
|
static const char *const MIGRATE_V23_TO_V24_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"ALTER TABLE session_preparations ADD COLUMN withdrawn_at TEXT;"
|
|
"CREATE TABLE session_preparation_withdrawals("
|
|
"withdrawal_id TEXT PRIMARY KEY,"
|
|
"preparation_id TEXT NOT NULL UNIQUE REFERENCES session_preparations(preparation_id),"
|
|
"revision_id TEXT NOT NULL REFERENCES session_preparation_revisions(revision_id),"
|
|
"requested_at TEXT NOT NULL,generation_id TEXT,acknowledged_at TEXT,"
|
|
"UNIQUE(preparation_id,revision_id));"
|
|
"PRAGMA user_version=24;COMMIT;";
|
|
|
|
/* WHY: reusable programs are planning definitions, not performed sessions or
|
|
* executable drafts. CONTRACT: v25 stores imported Program V1 documents and
|
|
* their ordered definitions separately, and records optional provenance on a
|
|
* preparation created from one definition. INVARIANT: migration invents no
|
|
* program, preparation, delivery, draft, or performed workout. */
|
|
static const char *const MIGRATE_V24_TO_V25_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE IF NOT EXISTS programs("
|
|
"program_id TEXT PRIMARY KEY,title TEXT NOT NULL,note TEXT,"
|
|
"state TEXT NOT NULL CHECK(state IN('active','archived')),"
|
|
"start_date TEXT,end_date TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,"
|
|
"revision_id TEXT NOT NULL,source_format TEXT NOT NULL,source_version INTEGER NOT NULL,"
|
|
"source_payload_sha256 TEXT NOT NULL,"
|
|
"CHECK(length(CAST(title AS BLOB)) BETWEEN 1 AND 200),"
|
|
"CHECK(note IS NULL OR length(CAST(note AS BLOB))<=4000),"
|
|
"CHECK(length(source_payload_sha256)=64),UNIQUE(program_id,source_payload_sha256));"
|
|
"CREATE TABLE IF NOT EXISTS program_sessions("
|
|
"program_session_id TEXT PRIMARY KEY,"
|
|
"program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
|
"position INTEGER NOT NULL CHECK(position>=0),title TEXT NOT NULL,"
|
|
"session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),"
|
|
"planned_for TEXT,note TEXT,"
|
|
"CHECK(length(CAST(title AS BLOB)) BETWEEN 1 AND 200),"
|
|
"CHECK(note IS NULL OR length(CAST(note AS BLOB))<=4000),"
|
|
"UNIQUE(program_id,position));"
|
|
"CREATE TABLE IF NOT EXISTS program_session_entries("
|
|
"program_session_id TEXT NOT NULL REFERENCES program_sessions(program_session_id) "
|
|
"ON DELETE RESTRICT,"
|
|
"entry_id TEXT NOT NULL,position INTEGER NOT NULL CHECK(position>=0),"
|
|
"exercise_id TEXT NOT NULL,equipment_id TEXT,"
|
|
"recording_mode TEXT NOT NULL CHECK(recording_mode IN('sets','continuous')),"
|
|
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration')),"
|
|
"data_fields INTEGER NOT NULL,"
|
|
"load_mode TEXT NOT NULL CHECK(load_mode IN('none','external','assistance')),"
|
|
"rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400),"
|
|
"target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,"
|
|
"target_weight_kg REAL,notes TEXT,"
|
|
"CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4000),"
|
|
"PRIMARY KEY(program_session_id,entry_id),UNIQUE(program_session_id,position));"
|
|
"CREATE INDEX IF NOT EXISTS program_session_entries_exercise "
|
|
"ON program_session_entries(exercise_id);"
|
|
"CREATE TABLE IF NOT EXISTS program_requests("
|
|
"request_id TEXT PRIMARY KEY,command TEXT NOT NULL,program_id TEXT NOT NULL,"
|
|
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
|
|
"CREATE TABLE IF NOT EXISTS web_session_deletion_requests("
|
|
"request_id TEXT PRIMARY KEY,resource_kind TEXT NOT NULL,target_id TEXT NOT NULL,"
|
|
"expected_revision TEXT NOT NULL,response_json TEXT NOT NULL,created_at TEXT NOT NULL,"
|
|
"UNIQUE(resource_kind,target_id));"
|
|
"PRAGMA user_version=25;COMMIT;";
|
|
|
|
/* WHY: deleting a reusable plan must not erase imported definitions, source
|
|
* sessions, or preparations already derived from them. CONTRACT: v26 adds a
|
|
* terminal logical marker and a durable command ledger. INVARIANT: migration
|
|
* deletes nothing and marks no existing program as deleted. */
|
|
static const char *const MIGRATE_V25_TO_V26_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"ALTER TABLE programs ADD COLUMN deleted_at TEXT;"
|
|
"CREATE TABLE program_deletions("
|
|
"program_id TEXT PRIMARY KEY REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
|
"request_id TEXT NOT NULL UNIQUE,expected_revision TEXT NOT NULL,"
|
|
"deleted_revision TEXT NOT NULL,deleted_at TEXT NOT NULL,response_json TEXT NOT NULL,"
|
|
"generation_id TEXT,acknowledged_at TEXT);"
|
|
"CREATE INDEX program_deletions_pending ON program_deletions("
|
|
"generation_id,deleted_at,request_id) WHERE acknowledged_at IS NULL;"
|
|
"PRAGMA user_version=26;COMMIT;";
|
|
|
|
/* WHY: Android can execute a desktop-owned program session offline, so the
|
|
* canonical desktop needs a durable stable-identity link after import.
|
|
* CONTRACT: rows describe execution provenance only and never own or mutate
|
|
* program definitions or completed-session history. INVARIANT: one program
|
|
* session has at most one execution and replaying the same fact is idempotent. */
|
|
static const char *const MIGRATE_V26_TO_V27_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE IF NOT EXISTS program_session_executions("
|
|
"program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) "
|
|
"ON DELETE RESTRICT,"
|
|
"program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
|
"session_id TEXT NOT NULL UNIQUE,"
|
|
"state TEXT NOT NULL CHECK(state IN('in_progress','completed')) ,"
|
|
"observed_at TEXT NOT NULL,"
|
|
"CHECK(length(program_id)>0 AND length(session_id)>0));"
|
|
"CREATE INDEX IF NOT EXISTS program_session_executions_program "
|
|
"ON program_session_executions(program_id,state);"
|
|
"PRAGMA user_version=27;COMMIT;";
|
|
|
|
/* WHY: a causally deleted completed session may retain immutable Program
|
|
* provenance after the session row disappears. CONTRACT: v28 adds only the
|
|
* terminal `deleted` execution state and preserves every existing identity and
|
|
* timestamp. INVARIANT: migration neither deletes history nor invents a
|
|
* Program execution or causal deletion. */
|
|
static const char *const MIGRATE_V27_TO_V28_SQL =
|
|
"PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;"
|
|
"CREATE TABLE program_session_executions_v28("
|
|
"program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) "
|
|
"ON DELETE RESTRICT,"
|
|
"program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
|
"session_id TEXT NOT NULL UNIQUE,"
|
|
"state TEXT NOT NULL CHECK(state IN('in_progress','completed','deleted')) ,"
|
|
"observed_at TEXT NOT NULL,"
|
|
"CHECK(length(program_id)>0 AND length(session_id)>0));"
|
|
"INSERT INTO program_session_executions_v28 "
|
|
"SELECT program_session_id,program_id,session_id,state,observed_at "
|
|
"FROM program_session_executions;"
|
|
"DROP INDEX program_session_executions_program;"
|
|
"DROP TABLE program_session_executions;"
|
|
"ALTER TABLE program_session_executions_v28 RENAME TO program_session_executions;"
|
|
"CREATE INDEX program_session_executions_program "
|
|
"ON program_session_executions(program_id,state);"
|
|
"PRAGMA user_version=28;COMMIT;PRAGMA foreign_keys=ON;";
|
|
|
|
/* WHY: sleep observations need minute-precise, timezone-aware facts that can
|
|
* later be correlated with physiological samples; a rendered 18:00 timeline
|
|
* is not a persistence model. CONTRACT: one stable diary identity owns a
|
|
* bounded causal revision chain and zero or more typed points/intervals.
|
|
* INVARIANT: wall-clock timestamps never order concurrent peer revisions and
|
|
* logical deletion retains the last revision for causal replay protection. */
|
|
static const char *const MIGRATE_V28_TO_V29_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE IF NOT EXISTS sleep_diary_entries("
|
|
"entry_id TEXT PRIMARY KEY,night_start_date TEXT NOT NULL,night_end_date TEXT NOT NULL,"
|
|
"created_at TEXT NOT NULL,updated_at TEXT NOT NULL,current_revision_id TEXT NOT NULL,"
|
|
"deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN(0,1)),"
|
|
"CHECK(entry_id GLOB 'sl_*'),CHECK(night_start_date<night_end_date));"
|
|
"CREATE TABLE IF NOT EXISTS sleep_diary_revisions("
|
|
"revision_id TEXT PRIMARY KEY,entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) "
|
|
"ON DELETE RESTRICT,parent_revision_id TEXT,created_at TEXT NOT NULL,"
|
|
"sleep_quality TEXT CHECK(sleep_quality IN('TB','B','Moy','M','TM')),"
|
|
"wake_quality TEXT CHECK(wake_quality IN('TB','B','Moy','M','TM')),"
|
|
"day_form TEXT CHECK(day_form IN('TB','B','Moy','M','TM')),"
|
|
"treatment_and_notes TEXT,"
|
|
"CHECK(treatment_and_notes IS NULL OR length(CAST(treatment_and_notes AS BLOB))<=16384),"
|
|
"UNIQUE(entry_id,revision_id));"
|
|
"CREATE TABLE IF NOT EXISTS sleep_diary_events("
|
|
"revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,"
|
|
"event_id TEXT NOT NULL,event_type TEXT NOT NULL CHECK(event_type IN("
|
|
"'bed_time','final_get_up','night_get_up','sleep','nap','long_awake','half_sleep',"
|
|
"'daytime_sleepiness')),start_at TEXT NOT NULL,end_at TEXT,"
|
|
"CHECK((event_type IN('bed_time','final_get_up','night_get_up','daytime_sleepiness') AND "
|
|
"end_at IS NULL) OR (event_type IN('sleep','nap','long_awake','half_sleep') AND "
|
|
"end_at IS NOT NULL)),PRIMARY KEY(revision_id,event_id));"
|
|
"CREATE INDEX IF NOT EXISTS sleep_diary_entries_dates ON "
|
|
"sleep_diary_entries(night_start_date,deleted);"
|
|
"CREATE INDEX IF NOT EXISTS sleep_diary_events_time ON sleep_diary_events(start_at,end_at);"
|
|
"CREATE TABLE IF NOT EXISTS sleep_medications("
|
|
"medication_id TEXT PRIMARY KEY,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,"
|
|
"current_revision_id TEXT NOT NULL,deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN(0,1)),"
|
|
"CHECK(medication_id GLOB 'med_*'));"
|
|
"CREATE TABLE IF NOT EXISTS sleep_medication_revisions("
|
|
"revision_id TEXT PRIMARY KEY,medication_id TEXT NOT NULL REFERENCES sleep_medications("
|
|
"medication_id) ON DELETE RESTRICT,parent_revision_id TEXT,created_at TEXT NOT NULL,"
|
|
"name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 160),"
|
|
"default_dose_value REAL CHECK(default_dose_value IS NULL OR default_dose_value>0),"
|
|
"default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER NOT NULL CHECK(active IN(0,1)),"
|
|
"CHECK((default_dose_value IS NULL AND default_dose_unit IS NULL) OR "
|
|
"(default_dose_value IS NOT NULL AND length(trim(default_dose_unit)) BETWEEN 1 AND 32)));"
|
|
"CREATE TABLE IF NOT EXISTS sleep_medication_intakes("
|
|
"revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,"
|
|
"intake_id TEXT NOT NULL,medication_id TEXT NOT NULL,medication_name TEXT NOT NULL,"
|
|
"taken_at TEXT NOT NULL,dose_value REAL,dose_unit TEXT,note TEXT,created_at TEXT NOT NULL,"
|
|
"PRIMARY KEY(revision_id,intake_id),CHECK(intake_id GLOB 'mdi_*'),"
|
|
"CHECK(length(trim(medication_name)) BETWEEN 1 AND 160),"
|
|
"CHECK((dose_value IS NULL AND dose_unit IS NULL) OR "
|
|
"(dose_value>0 AND length(trim(dose_unit)) BETWEEN 1 AND 32)));"
|
|
"CREATE INDEX IF NOT EXISTS sleep_medications_active ON sleep_medications(deleted,updated_at);"
|
|
"CREATE INDEX IF NOT EXISTS sleep_medication_intakes_time ON "
|
|
"sleep_medication_intakes(taken_at,medication_id);"
|
|
"CREATE TABLE IF NOT EXISTS sleep_diary_publication_state("
|
|
"entry_id TEXT PRIMARY KEY REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,"
|
|
"validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,"
|
|
"acknowledged_at TEXT,"
|
|
"CHECK((validated_revision_id IS NULL)=(validated_at IS NULL)),"
|
|
"CHECK((acknowledged_revision_id IS NULL)=(acknowledged_at IS NULL)));"
|
|
"CREATE TABLE IF NOT EXISTS sleep_diary_generation_entries("
|
|
"generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE RESTRICT,"
|
|
"entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,"
|
|
"revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,"
|
|
"PRIMARY KEY(generation_id,entry_id));"
|
|
"PRAGMA user_version=29;COMMIT;";
|
|
|
|
/* WHY: heart-rate is Android-owned measured field data, but desktop remains
|
|
* the canonical long-term synchronized store. CONTRACT: v30 adds only the
|
|
* immutable imported capture/sample tables. INVARIANT: no workout, Sleep
|
|
* Diary, generation or existing observation row is rewritten or inferred. */
|
|
static const char *const MIGRATE_V29_TO_V30_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE IF NOT EXISTS heart_rate_captures("
|
|
"capture_id TEXT PRIMARY KEY,context_kind TEXT NOT NULL CHECK(context_kind "
|
|
"IN('session','cardio','sleep')),"
|
|
"context_id TEXT NOT NULL,started_at TEXT NOT NULL,ended_at TEXT NOT NULL,sensor_name TEXT,"
|
|
"imported_at TEXT NOT NULL,CHECK(capture_id GLOB 'hrc_*'),"
|
|
"CHECK((context_kind IN('session','cardio') AND context_id GLOB 'se_*') OR "
|
|
"(context_kind='sleep' AND context_id GLOB 'sl_*')),"
|
|
"CHECK(sensor_name IS NULL OR length(CAST(sensor_name AS BLOB))<=160));"
|
|
"CREATE TABLE IF NOT EXISTS heart_rate_samples("
|
|
"capture_id TEXT NOT NULL REFERENCES heart_rate_captures(capture_id) ON DELETE CASCADE,"
|
|
"sequence INTEGER NOT NULL CHECK(sequence>=0),observed_at TEXT NOT NULL,"
|
|
"bpm INTEGER NOT NULL CHECK(bpm BETWEEN 0 AND 65535),exercise_entry_id TEXT,"
|
|
"sensor_contact_detected INTEGER CHECK(sensor_contact_detected IS NULL OR "
|
|
"sensor_contact_detected IN(0,1)),energy_expended INTEGER CHECK(energy_expended IS NULL OR "
|
|
"energy_expended BETWEEN 0 AND 65535),PRIMARY KEY(capture_id,sequence),"
|
|
"CHECK(exercise_entry_id IS NULL OR exercise_entry_id GLOB 'sxe_*'));"
|
|
"CREATE TABLE IF NOT EXISTS heart_rate_rr_intervals("
|
|
"capture_id TEXT NOT NULL,sample_sequence INTEGER NOT NULL,"
|
|
"rr_index INTEGER NOT NULL CHECK(rr_index BETWEEN 0 AND 63),"
|
|
"value_1024 INTEGER NOT NULL CHECK(value_1024 BETWEEN 0 AND 65535),"
|
|
"PRIMARY KEY(capture_id,sample_sequence,rr_index),"
|
|
"FOREIGN KEY(capture_id,sample_sequence) REFERENCES "
|
|
"heart_rate_samples(capture_id,sequence) ON DELETE CASCADE);"
|
|
"CREATE INDEX IF NOT EXISTS heart_rate_samples_time ON "
|
|
"heart_rate_samples(observed_at,capture_id,sequence);"
|
|
"CREATE INDEX IF NOT EXISTS heart_rate_captures_context ON "
|
|
"heart_rate_captures(context_kind,context_id,started_at);"
|
|
"PRAGMA user_version=30;COMMIT;";
|
|
|
|
/* WHY: exercise/cardio correlation requires exact user-action boundaries on
|
|
* the canonical desktop history. CONTRACT: v31 imports only Android-recorded
|
|
* session occurrence timing facts. INVARIANT: no session or measurement time
|
|
* is inferred from order, reps, plans or heart-rate samples. */
|
|
static const char *const MIGRATE_V30_TO_V31_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE IF NOT EXISTS session_exercise_timeline("
|
|
"session_id TEXT NOT NULL,entry_id TEXT NOT NULL,exercise_id TEXT NOT NULL,"
|
|
"started_at TEXT NOT NULL,ended_at TEXT NOT NULL,imported_at TEXT NOT NULL,"
|
|
"PRIMARY KEY(session_id,entry_id),"
|
|
"CHECK(session_id GLOB 'se_*'),CHECK(entry_id GLOB 'sxe_*'),"
|
|
"CHECK(exercise_id GLOB 'ex_*'));"
|
|
"CREATE INDEX IF NOT EXISTS session_exercise_timeline_time ON "
|
|
"session_exercise_timeline(started_at,session_id,entry_id);"
|
|
"PRAGMA user_version=31;COMMIT;";
|
|
|
|
/* WHY: legacy session_type is a frozen training/max_test compatibility column.
|
|
* CONTRACT: v32 adds canonical local session_kind and backfills it exactly
|
|
* from legacy history. INVARIANT: no legacy codec or MAX classification is
|
|
* widened to cardio. */
|
|
static const char *const MIGRATE_V31_TO_V32_SQL = "BEGIN IMMEDIATE;"
|
|
"PRAGMA user_version=32;COMMIT;";
|
|
|
|
/* WHY: guided cardio must retain the exact measured calibration reference
|
|
* independently from the raw HR samples. CONTRACT: v33 stores only completed
|
|
* imported calibration metadata/recovery points; the source curve remains in
|
|
* Heart Rate V1. INVARIANT: no session or HR sample is inferred or rewritten. */
|
|
static const char *const MIGRATE_V32_TO_V33_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE IF NOT EXISTS cardio_calibrations("
|
|
"calibration_id TEXT PRIMARY KEY,protocol_version INTEGER NOT NULL CHECK(protocol_version=1),"
|
|
"session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE RESTRICT,"
|
|
"entry_id TEXT NOT NULL,started_at TEXT NOT NULL,effort_end_at TEXT NOT NULL,"
|
|
"ended_at TEXT NOT NULL,heart_rate_capture_id TEXT NOT NULL "
|
|
"REFERENCES heart_rate_captures(capture_id) ON DELETE RESTRICT,"
|
|
"observed_peak_bpm INTEGER NOT NULL CHECK(observed_peak_bpm BETWEEN 0 AND 65535),"
|
|
"imported_at TEXT NOT NULL,CHECK(calibration_id GLOB 'cal_*'),"
|
|
"CHECK(session_id GLOB 'se_*'),CHECK(entry_id GLOB 'sxe_*'),"
|
|
"CHECK(heart_rate_capture_id GLOB 'hrc_*'));"
|
|
"CREATE TABLE IF NOT EXISTS cardio_calibration_recovery("
|
|
"calibration_id TEXT NOT NULL REFERENCES cardio_calibrations(calibration_id) ON DELETE CASCADE,"
|
|
"target_offset_seconds INTEGER NOT NULL CHECK(target_offset_seconds IN(60,120,180)),"
|
|
"observed_at TEXT NOT NULL,bpm INTEGER NOT NULL CHECK(bpm BETWEEN 0 AND 65535),"
|
|
"PRIMARY KEY(calibration_id,target_offset_seconds));"
|
|
"CREATE INDEX IF NOT EXISTS cardio_calibration_session ON "
|
|
"cardio_calibrations(session_id,ended_at,calibration_id);"
|
|
"PRAGMA user_version=33;COMMIT;";
|
|
|
|
/* WHY: guided cardio is derived control history, distinct from raw HR.
|
|
* CONTRACT: v34 stores imported completed guidance runs, exact target snapshots
|
|
* and instruction changes. INVARIANT: no BPM, phase, calibration or session is
|
|
* inferred or rewritten by migration. */
|
|
static const char *const MIGRATE_V33_TO_V34_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE IF NOT EXISTS cardio_guidance_runs("
|
|
"run_id TEXT PRIMARY KEY,session_id TEXT NOT NULL UNIQUE REFERENCES sessions(session_id) ON "
|
|
"DELETE RESTRICT,"
|
|
"started_at TEXT NOT NULL,ended_at TEXT NOT NULL,imported_at TEXT NOT NULL,"
|
|
"CHECK(run_id GLOB 'cgr_*'),CHECK(session_id GLOB 'se_*'));"
|
|
"CREATE TABLE IF NOT EXISTS cardio_guidance_phases("
|
|
"run_id TEXT NOT NULL REFERENCES cardio_guidance_runs(run_id) ON DELETE CASCADE,"
|
|
"phase_id TEXT NOT NULL UNIQUE,entry_id TEXT NOT NULL,position INTEGER NOT NULL "
|
|
"CHECK(position>=0),"
|
|
"kind TEXT NOT NULL CHECK(kind IN('warmup','work','recovery','cooldown')),"
|
|
"target_min_bpm INTEGER,target_max_bpm INTEGER,calibration_id TEXT,"
|
|
"calibration_observed_peak_bpm INTEGER,minimum_percent INTEGER,maximum_percent INTEGER,"
|
|
"exit_kind TEXT NOT NULL CHECK(exit_kind "
|
|
"IN('fixed_duration','enter_target','recover_below','duration_or_recover')),"
|
|
"exit_seconds INTEGER,exit_bpm INTEGER,started_at TEXT NOT NULL,ended_at TEXT NOT NULL,"
|
|
"final_instruction TEXT NOT NULL CHECK(final_instruction "
|
|
"IN('accelerate','maintain','slow_down','suspended')),"
|
|
"PRIMARY KEY(run_id,phase_id),UNIQUE(run_id,position),CHECK(phase_id GLOB 'cgp_*'),"
|
|
"CHECK(entry_id GLOB 'sxe_*'),"
|
|
"CHECK((target_min_bpm IS NULL AND target_max_bpm IS NULL) OR "
|
|
"(target_min_bpm BETWEEN 1 AND 65535 AND target_max_bpm BETWEEN target_min_bpm AND 65535)),"
|
|
"CHECK((calibration_id IS NULL AND calibration_observed_peak_bpm IS NULL AND "
|
|
"minimum_percent IS NULL AND maximum_percent IS NULL) OR "
|
|
"(calibration_id GLOB 'cal_*' AND calibration_observed_peak_bpm BETWEEN 1 AND 65535 AND "
|
|
"minimum_percent BETWEEN 1 AND 100 AND maximum_percent BETWEEN minimum_percent AND 100)),"
|
|
"CHECK((exit_kind='fixed_duration' AND exit_seconds>0 AND exit_bpm IS NULL) OR "
|
|
"(exit_kind='enter_target' AND exit_seconds IS NULL AND exit_bpm IS NULL AND target_min_bpm IS "
|
|
"NOT NULL) OR "
|
|
"(exit_kind='recover_below' AND exit_seconds IS NULL AND exit_bpm BETWEEN 1 AND 65535) OR "
|
|
"(exit_kind='duration_or_recover' AND exit_seconds>0 AND exit_bpm BETWEEN 1 AND 65535)));"
|
|
"CREATE TABLE IF NOT EXISTS cardio_guidance_events("
|
|
"run_id TEXT NOT NULL,phase_id TEXT NOT NULL,sequence INTEGER NOT NULL CHECK(sequence>=0),"
|
|
"observed_at TEXT NOT NULL,instruction TEXT NOT NULL "
|
|
"CHECK(instruction IN('accelerate','maintain','slow_down','suspended')),"
|
|
"bpm INTEGER CHECK(bpm IS NULL OR bpm BETWEEN 1 AND 65535),"
|
|
"target_min_bpm INTEGER,target_max_bpm INTEGER,"
|
|
"PRIMARY KEY(run_id,phase_id,sequence),"
|
|
"FOREIGN KEY(run_id,phase_id) REFERENCES cardio_guidance_phases(run_id,phase_id) ON DELETE "
|
|
"CASCADE,"
|
|
"CHECK((target_min_bpm IS NULL AND target_max_bpm IS NULL) OR "
|
|
"(target_min_bpm BETWEEN 1 AND 65535 AND target_max_bpm BETWEEN target_min_bpm AND 65535)));"
|
|
"CREATE INDEX IF NOT EXISTS cardio_guidance_session ON "
|
|
"cardio_guidance_runs(session_id,started_at,run_id);"
|
|
"CREATE INDEX IF NOT EXISTS cardio_guidance_event_time ON "
|
|
"cardio_guidance_events(observed_at,run_id,phase_id,sequence);"
|
|
"PRAGMA user_version=34;COMMIT;";
|
|
|
|
static TrainlogStatus ensure_v32_session_kind(TrainlogDatabase *database) {
|
|
sqlite3_stmt *statement = NULL;
|
|
bool found_kind = false;
|
|
bool found_legacy_type = false;
|
|
int result = sqlite3_prepare_v2(
|
|
database->connection, "PRAGMA table_info(sessions);", -1, &statement, NULL);
|
|
if (result != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
while ((result = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
const char *name = (const char *)sqlite3_column_text(statement, 1);
|
|
if (name != NULL && strcmp(name, "session_kind") == 0) {
|
|
found_kind = true;
|
|
} else if (name != NULL && strcmp(name, "session_type") == 0) {
|
|
found_legacy_type = true;
|
|
}
|
|
}
|
|
if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (!found_kind &&
|
|
execute_sql(database,
|
|
"ALTER TABLE sessions ADD COLUMN session_kind TEXT NOT NULL "
|
|
"DEFAULT 'training' CHECK(session_kind IN('training','max_test','cardio'));") !=
|
|
TRAINLOG_STATUS_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (found_legacy_type &&
|
|
execute_sql(database, "UPDATE sessions SET session_kind=session_type;") !=
|
|
TRAINLOG_STATUS_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static TrainlogStatus ensure_v35_medication_quantity(TrainlogDatabase *database) {
|
|
sqlite3_stmt *statement = NULL;
|
|
bool found = false;
|
|
int result = sqlite3_prepare_v2(
|
|
database->connection, "PRAGMA table_info(sleep_medication_intakes);", -1, &statement, NULL);
|
|
if (result != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
while ((result = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
const char *name = (const char *)sqlite3_column_text(statement, 1);
|
|
if (name != NULL && strcmp(name, "quantity") == 0) {
|
|
found = true;
|
|
}
|
|
}
|
|
if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (!found &&
|
|
execute_sql(database,
|
|
"ALTER TABLE sleep_medication_intakes ADD COLUMN quantity INTEGER NOT NULL "
|
|
"DEFAULT 1 CHECK(quantity BETWEEN 1 AND 99);") != TRAINLOG_STATUS_OK) {
|
|
(void)execute_sql(database, "ROLLBACK;");
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (execute_sql(database, "PRAGMA user_version=35;") != TRAINLOG_STATUS_OK ||
|
|
execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) {
|
|
(void)execute_sql(database, "ROLLBACK;");
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
/* WHY: revision identity is the causal content address used by synchronized
|
|
* peers; changing a persisted row in place can make two peers assign different
|
|
* facts to the same UUID. CONTRACT: v36 makes every Sleep revision payload
|
|
* append-only. Tips and publication markers remain mutable through their owning
|
|
* tables. INVARIANT: correction always appends a fresh revision identity. */
|
|
static const char *const MIGRATE_V35_TO_V36_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TRIGGER IF NOT EXISTS sleep_diary_revisions_immutable_update BEFORE UPDATE ON "
|
|
"sleep_diary_revisions BEGIN SELECT RAISE(ABORT,'sleep diary revision is immutable');END;"
|
|
"CREATE TRIGGER IF NOT EXISTS sleep_diary_revisions_immutable_delete BEFORE DELETE ON "
|
|
"sleep_diary_revisions BEGIN SELECT RAISE(ABORT,'sleep diary revision is immutable');END;"
|
|
"CREATE TRIGGER IF NOT EXISTS sleep_diary_events_immutable_update BEFORE UPDATE ON "
|
|
"sleep_diary_events "
|
|
"BEGIN SELECT RAISE(ABORT,'sleep diary event is immutable');END;"
|
|
"CREATE TRIGGER IF NOT EXISTS sleep_diary_events_immutable_delete BEFORE DELETE ON "
|
|
"sleep_diary_events "
|
|
"BEGIN SELECT RAISE(ABORT,'sleep diary event is immutable');END;"
|
|
"CREATE TRIGGER IF NOT EXISTS sleep_medication_intakes_immutable_update BEFORE UPDATE ON "
|
|
"sleep_medication_intakes BEGIN SELECT RAISE(ABORT,'sleep medication intake is immutable');END;"
|
|
"CREATE TRIGGER IF NOT EXISTS sleep_medication_intakes_immutable_delete BEFORE DELETE ON "
|
|
"sleep_medication_intakes BEGIN SELECT RAISE(ABORT,'sleep medication intake is immutable');END;"
|
|
"CREATE TRIGGER IF NOT EXISTS sleep_medication_revisions_immutable_update BEFORE UPDATE ON "
|
|
"sleep_medication_revisions BEGIN SELECT RAISE(ABORT,'sleep medication revision is "
|
|
"immutable');END;"
|
|
"CREATE TRIGGER IF NOT EXISTS sleep_medication_revisions_immutable_delete BEFORE DELETE ON "
|
|
"sleep_medication_revisions BEGIN SELECT RAISE(ABORT,'sleep medication revision is "
|
|
"immutable');END;"
|
|
"PRAGMA user_version=36;COMMIT;";
|
|
|
|
/* WHY: a Program's imported date is evidence, while an explicit reschedule
|
|
* owns its current target and an optional relinquishment of a future slot.
|
|
* CONTRACT: absent planning rows retain the original date and active state;
|
|
* operation identities bind replay to an exact request digest. INVARIANT:
|
|
* migration changes no execution, preparation, source date, or history row. */
|
|
static const char *const MIGRATE_V36_TO_V37_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE IF NOT EXISTS program_session_planning("
|
|
"program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) "
|
|
"ON DELETE RESTRICT,"
|
|
"current_for TEXT,"
|
|
"state TEXT NOT NULL CHECK(state IN('active','ceded')),"
|
|
"operation_id TEXT NOT NULL,updated_at TEXT NOT NULL,"
|
|
"CHECK((state='active' AND current_for IS NOT NULL) OR "
|
|
"(state='ceded' AND current_for IS NULL)));"
|
|
"CREATE TABLE IF NOT EXISTS program_reschedule_operations("
|
|
"operation_id TEXT PRIMARY KEY,"
|
|
"program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
|
"request_sha256 TEXT NOT NULL CHECK(length(request_sha256)=64),"
|
|
"expected_revision TEXT NOT NULL,"
|
|
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
|
|
"CREATE INDEX IF NOT EXISTS program_reschedule_operations_program "
|
|
"ON program_reschedule_operations(program_id,created_at);"
|
|
"CREATE TABLE IF NOT EXISTS program_revision_sequences("
|
|
"program_id TEXT PRIMARY KEY REFERENCES programs(program_id) ON DELETE RESTRICT,"
|
|
"revision_sequence INTEGER NOT NULL CHECK(revision_sequence>=1));"
|
|
"CREATE TRIGGER IF NOT EXISTS programs_revision_sequence AFTER UPDATE OF revision_id "
|
|
"ON programs WHEN OLD.revision_id<>NEW.revision_id BEGIN "
|
|
"INSERT INTO program_revision_sequences(program_id,revision_sequence) "
|
|
"VALUES(NEW.program_id,1) ON CONFLICT(program_id) DO UPDATE SET "
|
|
"revision_sequence=revision_sequence+1;END;"
|
|
"PRAGMA user_version=37;COMMIT;";
|
|
|
|
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
|
|
* before medication capture joined the same unreleased migration. CONTRACT:
|
|
* this additive repair is identical to the tail of v28 -> v29 and runs only
|
|
* while v29 remains the development schema. INVARIANT: existing diary rows
|
|
* are never rewritten and the transaction is safe to repeat on every open. */
|
|
static const char *const ENSURE_V29_SLEEP_MEDICATION_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"CREATE TABLE IF NOT EXISTS sleep_medications("
|
|
"medication_id TEXT PRIMARY KEY,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,"
|
|
"current_revision_id TEXT NOT NULL,deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN(0,1)),"
|
|
"CHECK(medication_id GLOB 'med_*'));"
|
|
"CREATE TABLE IF NOT EXISTS sleep_medication_revisions("
|
|
"revision_id TEXT PRIMARY KEY,medication_id TEXT NOT NULL REFERENCES sleep_medications("
|
|
"medication_id) ON DELETE RESTRICT,parent_revision_id TEXT,created_at TEXT NOT NULL,"
|
|
"name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 160),"
|
|
"default_dose_value REAL CHECK(default_dose_value IS NULL OR default_dose_value>0),"
|
|
"default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER NOT NULL CHECK(active IN(0,1)),"
|
|
"CHECK((default_dose_value IS NULL AND default_dose_unit IS NULL) OR "
|
|
"(default_dose_value IS NOT NULL AND length(trim(default_dose_unit)) BETWEEN 1 AND 32)));"
|
|
"CREATE TABLE IF NOT EXISTS sleep_medication_intakes("
|
|
"revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,"
|
|
"intake_id TEXT NOT NULL,medication_id TEXT NOT NULL,medication_name TEXT NOT NULL,"
|
|
"taken_at TEXT NOT NULL,dose_value REAL,dose_unit TEXT,note TEXT,created_at TEXT NOT NULL,"
|
|
"PRIMARY KEY(revision_id,intake_id),CHECK(intake_id GLOB 'mdi_*'),"
|
|
"CHECK(length(trim(medication_name)) BETWEEN 1 AND 160),"
|
|
"CHECK((dose_value IS NULL AND dose_unit IS NULL) OR "
|
|
"(dose_value>0 AND length(trim(dose_unit)) BETWEEN 1 AND 32)));"
|
|
"CREATE INDEX IF NOT EXISTS sleep_medications_active ON sleep_medications(deleted,updated_at);"
|
|
"CREATE INDEX IF NOT EXISTS sleep_medication_intakes_time ON "
|
|
"sleep_medication_intakes(taken_at,medication_id);"
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS sleep_diary_one_night ON "
|
|
"sleep_diary_entries(night_start_date);"
|
|
"CREATE TABLE IF NOT EXISTS sleep_diary_publication_state("
|
|
"entry_id TEXT PRIMARY KEY REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,"
|
|
"validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,"
|
|
"acknowledged_at TEXT,"
|
|
"CHECK((validated_revision_id IS NULL)=(validated_at IS NULL)),"
|
|
"CHECK((acknowledged_revision_id IS NULL)=(acknowledged_at IS NULL)));"
|
|
"CREATE TABLE IF NOT EXISTS sleep_diary_generation_entries("
|
|
"generation_id TEXT NOT NULL REFERENCES sync_generations(generation_id) ON DELETE RESTRICT,"
|
|
"entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,"
|
|
"revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,"
|
|
"PRIMARY KEY(generation_id,entry_id));COMMIT;";
|
|
|
|
/* CONTRACT: migration fixtures may retain additive v25 columns while
|
|
* deliberately lowering user_version. Add each provenance column only when
|
|
* absent; a genuine v24 database still receives both before opening returns. */
|
|
static TrainlogStatus ensure_v25_preparation_provenance(TrainlogDatabase *database) {
|
|
sqlite3_stmt *statement = NULL;
|
|
bool has_program_id = false;
|
|
bool has_program_session_id = false;
|
|
int result;
|
|
|
|
result = sqlite3_prepare_v2(
|
|
database->connection, "PRAGMA table_info(session_preparations);", -1, &statement, NULL);
|
|
if (result != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
while ((result = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
const char *name = (const char *)sqlite3_column_text(statement, 1);
|
|
if (name != NULL && strcmp(name, "source_program_id") == 0) {
|
|
has_program_id = true;
|
|
}
|
|
if (name != NULL && strcmp(name, "source_program_session_id") == 0) {
|
|
has_program_session_id = true;
|
|
}
|
|
}
|
|
if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (!has_program_id &&
|
|
execute_sql(database,
|
|
"ALTER TABLE session_preparations ADD COLUMN source_program_id TEXT "
|
|
"REFERENCES programs(program_id);") != TRAINLOG_STATUS_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (!has_program_session_id &&
|
|
execute_sql(database,
|
|
"ALTER TABLE session_preparations ADD COLUMN source_program_session_id TEXT "
|
|
"REFERENCES program_sessions(program_session_id);") != TRAINLOG_STATUS_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static TrainlogStatus ensure_v25_proposal_withdrawal(TrainlogDatabase *database) {
|
|
sqlite3_stmt *statement = NULL;
|
|
bool found = false;
|
|
int result;
|
|
|
|
result = sqlite3_prepare_v2(
|
|
database->connection, "PRAGMA table_info(ai_session_drafts);", -1, &statement, NULL);
|
|
if (result != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
while ((result = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
const char *name = (const char *)sqlite3_column_text(statement, 1);
|
|
|
|
if (name != NULL && strcmp(name, "withdrawn_at") == 0) {
|
|
found = true;
|
|
}
|
|
}
|
|
if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (!found &&
|
|
execute_sql(database, "ALTER TABLE ai_session_drafts ADD COLUMN withdrawn_at TEXT;") !=
|
|
TRAINLOG_STATUS_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static TrainlogStatus ensure_v25_web_deletion_requests(TrainlogDatabase *database) {
|
|
return execute_sql(database,
|
|
"CREATE TABLE IF NOT EXISTS web_session_deletion_requests("
|
|
"request_id TEXT PRIMARY KEY,resource_kind TEXT NOT NULL,"
|
|
"target_id TEXT NOT NULL,expected_revision TEXT NOT NULL,"
|
|
"response_json TEXT NOT NULL,created_at TEXT NOT NULL,"
|
|
"UNIQUE(resource_kind,target_id));");
|
|
}
|
|
|
|
/* CONTRACT: v18 was already deployed before bounded draft publication was
|
|
* added. Keep its version number and add only the nullable lifecycle cursor;
|
|
* NULL deliberately means retryable/not yet published. */
|
|
static TrainlogStatus ensure_v18_ai_draft_publication_state(TrainlogDatabase *database) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc;
|
|
bool found = false;
|
|
|
|
rc = sqlite3_prepare_v2(
|
|
database->connection, "PRAGMA table_info(ai_session_drafts);", -1, &statement, NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
const unsigned char *name = sqlite3_column_text(statement, 1);
|
|
if (name != NULL && strcmp((const char *)name, "published_at") == 0) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (rc != SQLITE_ROW && rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (!found &&
|
|
execute_sql(database, "ALTER TABLE ai_session_drafts ADD COLUMN published_at TEXT;") !=
|
|
TRAINLOG_STATUS_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
/* INVARIANT: older v18 tables may still declare a cascading FK. This
|
|
* guard runs before it and makes replay identity durable for both shapes. */
|
|
return execute_sql(database,
|
|
"CREATE TRIGGER IF NOT EXISTS ai_session_draft_import_identity_guard "
|
|
"BEFORE DELETE ON ai_session_drafts WHEN EXISTS(SELECT 1 FROM "
|
|
"ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) BEGIN "
|
|
"SELECT RAISE(ABORT,'AI draft import identity is permanent');END;");
|
|
}
|
|
|
|
/* WHY: the published-unreleased v17 prototype may already exist locally.
|
|
* CONTRACT: opening it adds the bounded durable lineage table without changing
|
|
* user_version or domain rows. INVARIANT: the guarded trigger records local
|
|
* edits, while an importer-selected known tip makes the trigger a no-op. */
|
|
static const char *const ENSURE_V17_PROFILE_REVISIONS_SQL =
|
|
"DROP TRIGGER IF EXISTS exercise_profile_state_insert;"
|
|
"DROP TRIGGER IF EXISTS exercise_profile_state_update;"
|
|
"CREATE TABLE IF NOT EXISTS exercise_profile_revisions("
|
|
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,"
|
|
"revision_id TEXT NOT NULL,parent_revision_id TEXT,recording_mode TEXT NOT NULL,"
|
|
"tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,legacy_seed INTEGER NOT NULL "
|
|
"CHECK(legacy_seed IN(0,1)),"
|
|
"PRIMARY KEY(exercise_row_id,revision_id));"
|
|
"INSERT OR IGNORE INTO exercise_profile_revisions "
|
|
"SELECT "
|
|
"e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,s."
|
|
"legacy_seed "
|
|
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id;"
|
|
"CREATE TRIGGER exercise_profile_state_insert AFTER INSERT ON exercises BEGIN "
|
|
"INSERT INTO exercise_profile_state "
|
|
"VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,"
|
|
"NEW.data_fields),'pr_legacy_v1',0);"
|
|
"INSERT INTO exercise_profile_revisions "
|
|
"VALUES(NEW.id,'pr_legacy_v1',NULL,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,1);"
|
|
"INSERT INTO exercise_profile_revisions "
|
|
"VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,"
|
|
"NEW.data_fields),'pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0);END;"
|
|
"CREATE TRIGGER exercise_profile_state_update AFTER UPDATE OF "
|
|
"recording_mode,tracking_mode,data_fields ON exercises "
|
|
"WHEN (OLD.recording_mode<>NEW.recording_mode OR OLD.tracking_mode<>NEW.tracking_mode OR "
|
|
"OLD.data_fields<>NEW.data_fields) "
|
|
"AND NOT EXISTS(SELECT 1 FROM exercise_profile_state s JOIN exercise_profile_revisions r ON "
|
|
"r.exercise_row_id=s.exercise_row_id AND r.revision_id=s.revision_id WHERE "
|
|
"s.exercise_row_id=NEW.id AND r.recording_mode=NEW.recording_mode AND "
|
|
"r.tracking_mode=NEW.tracking_mode AND r.data_fields=NEW.data_fields) BEGIN "
|
|
"SELECT CASE WHEN (SELECT COUNT(*) FROM exercise_profile_revisions WHERE "
|
|
"exercise_row_id=NEW.id)>=32 THEN RAISE(ABORT,'profile revision history limit') END;"
|
|
"INSERT OR IGNORE INTO exercise_profile_revisions SELECT "
|
|
"NEW.id,s.revision_id,s.parent_revision_id,OLD.recording_mode,OLD.tracking_mode,OLD.data_"
|
|
"fields,s.legacy_seed FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;"
|
|
"UPDATE exercise_profile_state SET "
|
|
"parent_revision_id=revision_id,revision_id=trainlog_profile_revision(revision_id,NEW."
|
|
"recording_mode,NEW.tracking_mode,NEW.data_fields),legacy_seed=0 WHERE exercise_row_id=NEW.id;"
|
|
"INSERT INTO exercise_profile_revisions SELECT "
|
|
"NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_"
|
|
"fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;";
|
|
|
|
/* CONTRACT: normalize only the exact unpublished pr1 current-state shape.
|
|
* Validation precedes every mutation, and the caller owns one transaction
|
|
* covering compatibility-table setup, conversion, and trigger replacement. */
|
|
static TrainlogStatus normalize_v17_prototype_profiles(TrainlogDatabase *database) {
|
|
int invalid = 0;
|
|
static const char *const VALIDATION_SQL =
|
|
"SELECT ("
|
|
"(SELECT COUNT(*) FROM exercise_profile_state s JOIN exercises e ON e.id=s.exercise_row_id "
|
|
" WHERE s.revision_id GLOB 'pr1*' AND (s.parent_revision_id IS NOT NULL OR "
|
|
"s.legacy_seed<>1 OR "
|
|
" trainlog_pr1_valid(s.revision_id,e.recording_mode,e.tracking_mode,e.data_fields)<>1)) +"
|
|
"(SELECT COUNT(*) FROM exercise_profile_revisions r LEFT JOIN exercise_profile_state s "
|
|
" ON s.exercise_row_id=r.exercise_row_id AND s.revision_id=r.revision_id "
|
|
" WHERE r.revision_id GLOB 'pr1*' AND (s.exercise_row_id IS NULL OR r.parent_revision_id "
|
|
"IS NOT NULL OR "
|
|
" r.legacy_seed<>1 OR r.recording_mode<>(SELECT recording_mode FROM exercises WHERE "
|
|
"id=r.exercise_row_id) OR "
|
|
" r.tracking_mode<>(SELECT tracking_mode FROM exercises WHERE id=r.exercise_row_id) OR "
|
|
" r.data_fields<>(SELECT data_fields FROM exercises WHERE id=r.exercise_row_id))) +"
|
|
"(SELECT COUNT(*) FROM exercise_profile_state s WHERE s.revision_id GLOB 'pr1*' AND "
|
|
" (SELECT COUNT(*) FROM exercise_profile_revisions r WHERE "
|
|
"r.exercise_row_id=s.exercise_row_id)<>1)"
|
|
");";
|
|
static const char *const NORMALIZE_SQL =
|
|
"DELETE FROM exercise_profile_revisions WHERE revision_id GLOB 'pr1*';"
|
|
"INSERT INTO exercise_profile_revisions "
|
|
"SELECT e.id,'pr_legacy_v1',NULL,e.recording_mode,e.tracking_mode,e.data_fields,1 "
|
|
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id "
|
|
"WHERE s.revision_id GLOB 'pr1*';"
|
|
"INSERT INTO exercise_profile_revisions "
|
|
"SELECT "
|
|
"e.id,trainlog_profile_revision('pr_legacy_v1',e.recording_mode,e.tracking_mode,e.data_"
|
|
"fields),"
|
|
"'pr_legacy_v1',e.recording_mode,e.tracking_mode,e.data_fields,0 "
|
|
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id "
|
|
"WHERE s.revision_id GLOB 'pr1*';"
|
|
"UPDATE exercise_profile_state SET parent_revision_id='pr_legacy_v1',legacy_seed=0,"
|
|
"revision_id=trainlog_profile_revision('pr_legacy_v1',"
|
|
"(SELECT recording_mode FROM exercises WHERE id=exercise_row_id),"
|
|
"(SELECT tracking_mode FROM exercises WHERE id=exercise_row_id),"
|
|
"(SELECT data_fields FROM exercises WHERE id=exercise_row_id)) "
|
|
"WHERE revision_id GLOB 'pr1*';";
|
|
if (read_single_int_pragma(database, VALIDATION_SQL, &invalid) != TRAINLOG_STATUS_OK ||
|
|
invalid != 0) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return execute_sql(database, NORMALIZE_SQL);
|
|
}
|
|
|
|
static const char *const MIGRATE_V1_TO_V3_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"ALTER TABLE sessions "
|
|
"ADD COLUMN session_type TEXT NOT NULL DEFAULT 'training' "
|
|
"CHECK (session_type IN ('training', 'max_test'));"
|
|
"ALTER TABLE exercises "
|
|
"ADD COLUMN recording_mode TEXT NOT NULL DEFAULT 'sets' "
|
|
"CHECK (recording_mode IN ('sets', 'continuous'));"
|
|
"ALTER TABLE exercises "
|
|
"ADD COLUMN data_fields INTEGER NOT NULL DEFAULT 0 "
|
|
"CHECK (data_fields >= 0 AND (data_fields & ~3) = 0);"
|
|
"ALTER TABLE session_exercises "
|
|
"ADD COLUMN recording_mode TEXT NOT NULL DEFAULT 'sets' "
|
|
"CHECK (recording_mode IN ('sets', 'continuous'));"
|
|
"ALTER TABLE session_exercises "
|
|
"ADD COLUMN data_fields INTEGER NOT NULL DEFAULT 0 "
|
|
"CHECK (data_fields >= 0 AND (data_fields & ~3) = 0);"
|
|
"CREATE TABLE continuous_activity ("
|
|
"id INTEGER PRIMARY KEY,"
|
|
"session_exercise_row_id INTEGER NOT NULL UNIQUE "
|
|
"REFERENCES session_exercises(id) ON DELETE CASCADE,"
|
|
"duration_seconds INTEGER NOT NULL CHECK (duration_seconds > 0),"
|
|
"speed_kmh REAL CHECK (speed_kmh > 0.0),"
|
|
"distance_km REAL CHECK (distance_km > 0.0)"
|
|
");"
|
|
"PRAGMA user_version = 3;"
|
|
"COMMIT;";
|
|
|
|
static const char *const MIGRATE_V2_TO_V3_SQL =
|
|
"BEGIN IMMEDIATE;"
|
|
"ALTER TABLE exercises "
|
|
"ADD COLUMN recording_mode TEXT NOT NULL DEFAULT 'sets' "
|
|
"CHECK (recording_mode IN ('sets', 'continuous'));"
|
|
"ALTER TABLE exercises "
|
|
"ADD COLUMN data_fields INTEGER NOT NULL DEFAULT 0 "
|
|
"CHECK (data_fields >= 0 AND (data_fields & ~3) = 0);"
|
|
"ALTER TABLE session_exercises "
|
|
"ADD COLUMN recording_mode TEXT NOT NULL DEFAULT 'sets' "
|
|
"CHECK (recording_mode IN ('sets', 'continuous'));"
|
|
"ALTER TABLE session_exercises "
|
|
"ADD COLUMN data_fields INTEGER NOT NULL DEFAULT 0 "
|
|
"CHECK (data_fields >= 0 AND (data_fields & ~3) = 0);"
|
|
"CREATE TABLE continuous_activity ("
|
|
"id INTEGER PRIMARY KEY,"
|
|
"session_exercise_row_id INTEGER NOT NULL UNIQUE "
|
|
"REFERENCES session_exercises(id) ON DELETE CASCADE,"
|
|
"duration_seconds INTEGER NOT NULL CHECK (duration_seconds > 0),"
|
|
"speed_kmh REAL CHECK (speed_kmh > 0.0),"
|
|
"distance_km REAL CHECK (distance_km > 0.0)"
|
|
");"
|
|
"PRAGMA user_version = 3;"
|
|
"COMMIT;";
|
|
|
|
static const char *const MIGRATE_V3_TO_V4_SQL_A =
|
|
"BEGIN IMMEDIATE;"
|
|
|
|
"CREATE TABLE session_exercises_v4 ("
|
|
" id INTEGER PRIMARY KEY,"
|
|
" session_row_id INTEGER NOT NULL"
|
|
" REFERENCES sessions(id) ON DELETE CASCADE,"
|
|
" exercise_row_id INTEGER NOT NULL"
|
|
" REFERENCES exercises(id) ON DELETE RESTRICT,"
|
|
" recording_mode TEXT NOT NULL"
|
|
" CHECK (recording_mode IN ('sets', 'continuous')),"
|
|
" data_fields INTEGER NOT NULL DEFAULT 0"
|
|
" CHECK (data_fields >= 0 AND (data_fields & ~3) = 0),"
|
|
" position INTEGER NOT NULL CHECK (position >= 0),"
|
|
" load_mode TEXT NOT NULL"
|
|
" CHECK (load_mode IN ('none', 'external', 'assistance')),"
|
|
" rest_seconds INTEGER NOT NULL CHECK (rest_seconds >= 0),"
|
|
" target_sets INTEGER CHECK (target_sets > 0),"
|
|
" target_reps INTEGER CHECK (target_reps >= 1),"
|
|
" target_duration_seconds INTEGER"
|
|
" CHECK (target_duration_seconds > 0),"
|
|
" target_weight_kg REAL CHECK (target_weight_kg > 0.0),"
|
|
" notes TEXT,"
|
|
" UNIQUE (session_row_id, position),"
|
|
" UNIQUE (session_row_id, exercise_row_id),"
|
|
" CHECK ("
|
|
" (recording_mode = 'sets' AND"
|
|
" target_sets IS NOT NULL AND"
|
|
" ((target_reps IS NOT NULL AND"
|
|
" target_duration_seconds IS NULL) OR"
|
|
" (target_reps IS NULL AND"
|
|
" target_duration_seconds IS NOT NULL))) OR"
|
|
" (recording_mode = 'continuous' AND"
|
|
" target_sets IS NULL AND"
|
|
" target_reps IS NULL AND"
|
|
" target_duration_seconds IS NULL AND"
|
|
" load_mode = 'none' AND"
|
|
" rest_seconds = 0 AND"
|
|
" target_weight_kg IS NULL)"
|
|
" ),"
|
|
" CHECK ("
|
|
" (load_mode = 'none' AND target_weight_kg IS NULL) OR"
|
|
" (load_mode IN ('external', 'assistance') AND"
|
|
" target_weight_kg IS NOT NULL)"
|
|
" )"
|
|
");"
|
|
|
|
"INSERT INTO session_exercises_v4("
|
|
"id, session_row_id, exercise_row_id, recording_mode, data_fields,"
|
|
"position, load_mode, rest_seconds, target_sets, target_reps,"
|
|
"target_duration_seconds, target_weight_kg, notes"
|
|
") SELECT "
|
|
"id, session_row_id, exercise_row_id, recording_mode, data_fields,"
|
|
"position, load_mode, rest_seconds, target_sets, target_reps,"
|
|
"target_duration_seconds, target_weight_kg, notes "
|
|
"FROM session_exercises;"
|
|
|
|
"CREATE TABLE performed_sets_v4 ("
|
|
" id INTEGER PRIMARY KEY,"
|
|
" session_exercise_row_id INTEGER NOT NULL"
|
|
" REFERENCES session_exercises_v4(id) ON DELETE CASCADE,"
|
|
" position INTEGER NOT NULL CHECK (position >= 0),"
|
|
" reps INTEGER CHECK (reps >= 0),"
|
|
" duration_seconds INTEGER CHECK (duration_seconds > 0),"
|
|
" weight_kg REAL CHECK (weight_kg > 0.0),"
|
|
" UNIQUE (session_exercise_row_id, position),"
|
|
" CHECK ("
|
|
" (reps IS NOT NULL AND duration_seconds IS NULL) OR"
|
|
" (reps IS NULL AND duration_seconds IS NOT NULL)"
|
|
" )"
|
|
");"
|
|
|
|
"INSERT INTO performed_sets_v4("
|
|
"id, session_exercise_row_id, position, reps,"
|
|
"duration_seconds, weight_kg"
|
|
") SELECT "
|
|
"id, session_exercise_row_id, position, reps,"
|
|
"duration_seconds, weight_kg "
|
|
"FROM performed_sets;";
|
|
|
|
static const char *const MIGRATE_V3_TO_V4_SQL_B =
|
|
"CREATE TABLE continuous_activity_v4 ("
|
|
" id INTEGER PRIMARY KEY,"
|
|
" session_exercise_row_id INTEGER NOT NULL UNIQUE"
|
|
" REFERENCES session_exercises_v4(id) ON DELETE CASCADE,"
|
|
" duration_seconds INTEGER NOT NULL CHECK (duration_seconds > 0),"
|
|
" speed_kmh REAL CHECK (speed_kmh > 0.0),"
|
|
" distance_km REAL CHECK (distance_km > 0.0)"
|
|
");"
|
|
|
|
"INSERT INTO continuous_activity_v4("
|
|
"id, session_exercise_row_id, duration_seconds,"
|
|
"speed_kmh, distance_km"
|
|
") SELECT "
|
|
"id, session_exercise_row_id, duration_seconds,"
|
|
"speed_kmh, distance_km "
|
|
"FROM continuous_activity;"
|
|
|
|
"DROP TABLE continuous_activity;"
|
|
"DROP TABLE performed_sets;"
|
|
"DROP TABLE session_exercises;"
|
|
|
|
"ALTER TABLE session_exercises_v4"
|
|
" RENAME TO session_exercises;"
|
|
"ALTER TABLE performed_sets_v4"
|
|
" RENAME TO performed_sets;"
|
|
"ALTER TABLE continuous_activity_v4"
|
|
" RENAME TO continuous_activity;"
|
|
|
|
"PRAGMA user_version = 4;"
|
|
"COMMIT;";
|
|
|
|
static const char *const MIGRATE_V4_TO_V5_SQL_A =
|
|
"BEGIN IMMEDIATE;"
|
|
|
|
"CREATE TABLE session_exercises_v5 ("
|
|
" id INTEGER PRIMARY KEY,"
|
|
" session_row_id INTEGER NOT NULL"
|
|
" REFERENCES sessions(id) ON DELETE CASCADE,"
|
|
" exercise_row_id INTEGER NOT NULL"
|
|
" REFERENCES exercises(id) ON DELETE RESTRICT,"
|
|
" recording_mode TEXT NOT NULL DEFAULT 'sets'"
|
|
" CHECK (recording_mode IN ('sets', 'continuous')),"
|
|
" data_fields INTEGER NOT NULL DEFAULT 0"
|
|
" CHECK (data_fields >= 0 AND (data_fields & ~3) = 0),"
|
|
" position INTEGER NOT NULL CHECK (position >= 0),"
|
|
" load_mode TEXT NOT NULL"
|
|
" CHECK (load_mode IN ('none', 'external', 'assistance')),"
|
|
" rest_seconds INTEGER NOT NULL CHECK (rest_seconds >= 0),"
|
|
" target_sets INTEGER CHECK (target_sets > 0),"
|
|
" target_reps INTEGER CHECK (target_reps >= 1),"
|
|
" target_duration_seconds INTEGER"
|
|
" CHECK (target_duration_seconds > 0),"
|
|
" target_weight_kg REAL CHECK (target_weight_kg > 0.0),"
|
|
" notes TEXT,"
|
|
" UNIQUE (session_row_id, position),"
|
|
" UNIQUE (session_row_id, exercise_row_id),"
|
|
" CHECK ("
|
|
" (recording_mode = 'sets' AND"
|
|
" ("
|
|
" (target_sets IS NULL AND"
|
|
" target_reps IS NULL AND"
|
|
" target_duration_seconds IS NULL) OR"
|
|
" (target_sets IS NOT NULL AND"
|
|
" ((target_reps IS NOT NULL AND"
|
|
" target_duration_seconds IS NULL) OR"
|
|
" (target_reps IS NULL AND"
|
|
" target_duration_seconds IS NOT NULL)))"
|
|
" )) OR"
|
|
" (recording_mode = 'continuous' AND"
|
|
" target_sets IS NULL AND"
|
|
" target_reps IS NULL AND"
|
|
" target_duration_seconds IS NULL AND"
|
|
" load_mode = 'none' AND"
|
|
" rest_seconds = 0 AND"
|
|
" target_weight_kg IS NULL)"
|
|
" ),"
|
|
" CHECK ("
|
|
" (load_mode = 'none' AND target_weight_kg IS NULL) OR"
|
|
" (load_mode IN ('external', 'assistance') AND"
|
|
" target_weight_kg IS NOT NULL)"
|
|
" )"
|
|
");"
|
|
|
|
"INSERT INTO session_exercises_v5("
|
|
"id, session_row_id, exercise_row_id, recording_mode, data_fields,"
|
|
"position, load_mode, rest_seconds, target_sets, target_reps,"
|
|
"target_duration_seconds, target_weight_kg, notes"
|
|
") SELECT "
|
|
"id, session_row_id, exercise_row_id, recording_mode, data_fields,"
|
|
"position, load_mode, rest_seconds, target_sets, target_reps,"
|
|
"target_duration_seconds, target_weight_kg, notes "
|
|
"FROM session_exercises;"
|
|
|
|
"CREATE TABLE performed_sets_v5 ("
|
|
" id INTEGER PRIMARY KEY,"
|
|
" session_exercise_row_id INTEGER NOT NULL"
|
|
" REFERENCES session_exercises_v5(id) ON DELETE CASCADE,"
|
|
" position INTEGER NOT NULL CHECK (position >= 0),"
|
|
" reps INTEGER CHECK (reps >= 0),"
|
|
" duration_seconds INTEGER CHECK (duration_seconds > 0),"
|
|
" weight_kg REAL CHECK (weight_kg > 0.0),"
|
|
" UNIQUE (session_exercise_row_id, position),"
|
|
" CHECK ("
|
|
" (reps IS NOT NULL AND duration_seconds IS NULL) OR"
|
|
" (reps IS NULL AND duration_seconds IS NOT NULL)"
|
|
" )"
|
|
");"
|
|
|
|
"INSERT INTO performed_sets_v5("
|
|
"id, session_exercise_row_id, position, reps,"
|
|
"duration_seconds, weight_kg"
|
|
") SELECT "
|
|
"id, session_exercise_row_id, position, reps,"
|
|
"duration_seconds, weight_kg "
|
|
"FROM performed_sets;";
|
|
|
|
static const char *const MIGRATE_V4_TO_V5_SQL_B =
|
|
"CREATE TABLE continuous_activity_v5 ("
|
|
" id INTEGER PRIMARY KEY,"
|
|
" session_exercise_row_id INTEGER NOT NULL UNIQUE"
|
|
" REFERENCES session_exercises_v5(id) ON DELETE CASCADE,"
|
|
" duration_seconds INTEGER NOT NULL CHECK (duration_seconds > 0),"
|
|
" speed_kmh REAL CHECK (speed_kmh > 0.0),"
|
|
" distance_km REAL CHECK (distance_km > 0.0)"
|
|
");"
|
|
|
|
"INSERT INTO continuous_activity_v5("
|
|
"id, session_exercise_row_id, duration_seconds,"
|
|
"speed_kmh, distance_km"
|
|
") SELECT "
|
|
"id, session_exercise_row_id, duration_seconds,"
|
|
"speed_kmh, distance_km "
|
|
"FROM continuous_activity;"
|
|
|
|
"DROP TABLE continuous_activity;"
|
|
"DROP TABLE performed_sets;"
|
|
"DROP TABLE session_exercises;"
|
|
|
|
"ALTER TABLE session_exercises_v5"
|
|
" RENAME TO session_exercises;"
|
|
"ALTER TABLE performed_sets_v5"
|
|
" RENAME TO performed_sets;"
|
|
"ALTER TABLE continuous_activity_v5"
|
|
" RENAME TO continuous_activity;"
|
|
|
|
"PRAGMA user_version = 5;"
|
|
"COMMIT;";
|
|
|
|
static TrainlogStatus execute_sql(TrainlogDatabase *database, const char *sql) {
|
|
if (database == NULL || database->connection == NULL || sql == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
if (sqlite3_exec(database->connection, sql, NULL, NULL, NULL) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static bool bind_initial_body_zone(sqlite3_stmt *statement,
|
|
const char *exercise_id,
|
|
const char *zone_id,
|
|
const char *role) {
|
|
int rc;
|
|
if (sqlite3_reset(statement) != SQLITE_OK || sqlite3_clear_bindings(statement) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 2, zone_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 3, role, -1, SQLITE_STATIC) != SQLITE_OK) {
|
|
return false;
|
|
}
|
|
rc = sqlite3_step(statement);
|
|
return rc == SQLITE_DONE;
|
|
}
|
|
|
|
static TrainlogStatus migrate_v10_to_v11(TrainlogDatabase *database) {
|
|
static const char *const INSERT_SQL =
|
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) "
|
|
"SELECT id,?2,?3 FROM exercises WHERE exercise_id=?1;";
|
|
sqlite3_stmt *statement = NULL;
|
|
size_t index;
|
|
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
|
|
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK ||
|
|
execute_sql(database, CREATE_BODY_ZONE_RELATIONS_SQL) != TRAINLOG_STATUS_OK ||
|
|
sqlite3_prepare_v2(database->connection, INSERT_SQL, -1, &statement, NULL) != SQLITE_OK) {
|
|
goto rollback;
|
|
}
|
|
|
|
for (index = 0U; index < trainlog_body_zone_initial_mapping_count(); ++index) {
|
|
const TrainlogBodyZoneInitialMapping *mapping =
|
|
trainlog_body_zone_initial_mapping_at(index);
|
|
const char *cursor;
|
|
if (mapping == NULL ||
|
|
!bind_initial_body_zone(
|
|
statement, mapping->exercise_id, mapping->primary_zone_id, "primary")) {
|
|
goto rollback;
|
|
}
|
|
cursor = mapping->secondary_zone_ids;
|
|
while (cursor != NULL && cursor[0] != '\0') {
|
|
const char *end = strchr(cursor, '\n');
|
|
size_t length = end == NULL ? strlen(cursor) : (size_t)(end - cursor);
|
|
char zone_id[TRAINLOG_ZONE_ID_MAX + 1U];
|
|
if (length == 0U || length > TRAINLOG_ZONE_ID_MAX) {
|
|
goto rollback;
|
|
}
|
|
(void)memcpy(zone_id, cursor, length);
|
|
zone_id[length] = '\0';
|
|
if (!bind_initial_body_zone(statement, mapping->exercise_id, zone_id, "secondary")) {
|
|
goto rollback;
|
|
}
|
|
cursor = end == NULL ? NULL : end + 1;
|
|
}
|
|
}
|
|
|
|
/* CONTRACT: synced_state is an internal comparison baseline, not domain
|
|
* data. `primary|secondary,...` is deterministic because zone IDs exclude
|
|
* delimiters and secondary IDs are sorted bytewise. */
|
|
if (execute_sql(database,
|
|
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) "
|
|
"SELECT e.id,COALESCE((SELECT p.zone_id FROM exercise_body_zones p "
|
|
"WHERE p.exercise_row_id=e.id AND p.role='primary'),'')||'|'||"
|
|
"COALESCE((SELECT group_concat(s.zone_id,',') FROM "
|
|
"(SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=e.id "
|
|
"AND role='secondary' ORDER BY zone_id) s),'') FROM exercises e;") !=
|
|
TRAINLOG_STATUS_OK) {
|
|
goto rollback;
|
|
}
|
|
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
statement = NULL;
|
|
goto rollback;
|
|
}
|
|
statement = NULL;
|
|
if (execute_sql(database, "PRAGMA user_version = 11;COMMIT;") != TRAINLOG_STATUS_OK) {
|
|
goto rollback;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
|
|
rollback:
|
|
if (statement != NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
}
|
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
|
return status;
|
|
}
|
|
|
|
static TrainlogStatus
|
|
read_single_int_pragma(TrainlogDatabase *database, const char *sql, int *output) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || sql == NULL || output == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
if (rc != SQLITE_ROW) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
*output = sqlite3_column_int(statement, 0);
|
|
|
|
rc = sqlite3_finalize(statement);
|
|
return rc == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static TrainlogStatus list_feedback_query(TrainlogDatabase *database,
|
|
const char *sql,
|
|
const char *session_id,
|
|
TrainlogFeedbackView *output,
|
|
size_t capacity,
|
|
size_t *output_count,
|
|
bool has_entry) {
|
|
sqlite3_stmt *statement = NULL;
|
|
size_t count = 0U;
|
|
int rc;
|
|
if (database == NULL || sql == NULL || session_id == NULL || output == NULL ||
|
|
output_count == NULL || capacity == 0U) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
while (count < capacity && (rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
const unsigned char *stable = sqlite3_column_text(statement, 0);
|
|
const unsigned char *entry = has_entry ? sqlite3_column_text(statement, 1) : NULL;
|
|
const unsigned char *at = sqlite3_column_text(statement, has_entry ? 2 : 1);
|
|
const unsigned char *text = sqlite3_column_text(statement, has_entry ? 3 : 2);
|
|
if (stable == NULL || at == NULL || text == NULL ||
|
|
strlen((const char *)text) > TRAINLOG_FEEDBACK_TEXT_MAX) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
(void)snprintf(output[count].stable_id, sizeof(output[count].stable_id), "%s", stable);
|
|
(void)snprintf(output[count].entry_id,
|
|
sizeof(output[count].entry_id),
|
|
"%s",
|
|
entry != NULL ? (const char *)entry : "");
|
|
(void)snprintf(output[count].observed_at, sizeof(output[count].observed_at), "%s", at);
|
|
(void)snprintf(output[count].raw_text, sizeof(output[count].raw_text), "%s", text);
|
|
count += 1U;
|
|
}
|
|
if (rc != SQLITE_DONE && count < capacity) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
rc = sqlite3_finalize(statement);
|
|
*output_count = count;
|
|
return rc == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static int compare_feedback_view(const void *left_value, const void *right_value) {
|
|
const TrainlogFeedbackView *left = left_value;
|
|
const TrainlogFeedbackView *right = right_value;
|
|
TrainlogTimestampKey left_key, right_key;
|
|
int compared;
|
|
if (!trainlog_timestamp_parse(left->observed_at, strlen(left->observed_at), &left_key) ||
|
|
!trainlog_timestamp_parse(right->observed_at, strlen(right->observed_at), &right_key)) {
|
|
return strcmp(left->stable_id, right->stable_id);
|
|
}
|
|
compared = trainlog_timestamp_compare(&left_key, &right_key);
|
|
return compared != 0 ? compared : strcmp(left->stable_id, right->stable_id);
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_list_training_feedback(TrainlogDatabase *database,
|
|
const char *session_id,
|
|
TrainlogFeedbackView *exercise_feedback,
|
|
size_t exercise_capacity,
|
|
size_t *exercise_count,
|
|
TrainlogFeedbackView *followups,
|
|
size_t followup_capacity,
|
|
size_t *followup_count) {
|
|
TrainlogStatus status = list_feedback_query(
|
|
database,
|
|
"SELECT f.feedback_id,se.entry_id,f.observed_at,f.raw_text FROM exercise_feedback f "
|
|
"JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON "
|
|
"s.id=se.session_row_id "
|
|
"WHERE s.session_id=?1 AND NOT EXISTS(SELECT 1 FROM sync_causal_state cs "
|
|
"WHERE cs.target_kind='feedback' AND cs.target_id=f.feedback_id AND cs.deleted=1) "
|
|
"ORDER BY f.observed_at COLLATE BINARY,f.feedback_id COLLATE BINARY",
|
|
session_id,
|
|
exercise_feedback,
|
|
exercise_capacity,
|
|
exercise_count,
|
|
true);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
qsort(exercise_feedback, *exercise_count, sizeof(*exercise_feedback), compare_feedback_view);
|
|
status = list_feedback_query(
|
|
database,
|
|
"SELECT f.followup_id,f.observed_at,f.raw_text FROM session_followups f "
|
|
"JOIN sessions s ON s.id=f.session_row_id WHERE s.session_id=?1 "
|
|
"ORDER BY f.observed_at COLLATE BINARY,f.followup_id COLLATE BINARY",
|
|
session_id,
|
|
followups,
|
|
followup_capacity,
|
|
followup_count,
|
|
false);
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
qsort(followups, *followup_count, sizeof(*followups), compare_feedback_view);
|
|
}
|
|
return status;
|
|
}
|
|
|
|
static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|
char *output_diagnostic,
|
|
size_t output_diagnostic_capacity) {
|
|
int version = 0;
|
|
TrainlogStatus status;
|
|
|
|
status = trainlog_database_schema_version(database, &version);
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
set_open_diagnostic(output_diagnostic,
|
|
output_diagnostic_capacity,
|
|
"read schema version",
|
|
database->connection,
|
|
SQLITE_ERROR);
|
|
return status;
|
|
}
|
|
|
|
if (version > TRAINLOG_DATABASE_SCHEMA_VERSION) {
|
|
char message[128];
|
|
(void)snprintf(message,
|
|
sizeof(message),
|
|
"schema version %d is newer than supported version %d",
|
|
version,
|
|
TRAINLOG_DATABASE_SCHEMA_VERSION);
|
|
set_application_diagnostic(output_diagnostic, output_diagnostic_capacity, message);
|
|
return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
|
}
|
|
|
|
if (version == 0) {
|
|
status = execute_sql(database, SCHEMA_V7_SQL_A);
|
|
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, SCHEMA_V7_SQL_B);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V7_TO_V8_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
|
}
|
|
} else if (version == 7) {
|
|
/* CONTRACT: v7 is the immediate historic schema and must open through
|
|
* its lossless custom-equipment-table migration. */
|
|
status = execute_sql(database, MIGRATE_V7_TO_V8_SQL);
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
|
}
|
|
} else if (version == 8) {
|
|
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
|
}
|
|
} else if (version == 9) {
|
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
|
} else if (version == 10) {
|
|
status = TRAINLOG_STATUS_OK;
|
|
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
|
|
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
|
|
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
|
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
|
|
version == 31 || version == 32 || version == 33 || version == 34 || version == 35 ||
|
|
version == 36 || version == 37) {
|
|
status = TRAINLOG_STATUS_OK;
|
|
} else {
|
|
if (version == 1) {
|
|
status = execute_sql(database, MIGRATE_V1_TO_V3_SQL);
|
|
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V3_TO_V4_SQL_A);
|
|
}
|
|
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V3_TO_V4_SQL_B);
|
|
}
|
|
} else if (version == 2) {
|
|
status = execute_sql(database, MIGRATE_V2_TO_V3_SQL);
|
|
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V3_TO_V4_SQL_A);
|
|
}
|
|
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V3_TO_V4_SQL_B);
|
|
}
|
|
} else if (version == 3) {
|
|
status = execute_sql(database, MIGRATE_V3_TO_V4_SQL_A);
|
|
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V3_TO_V4_SQL_B);
|
|
}
|
|
} else if (version == 4) {
|
|
status = TRAINLOG_STATUS_OK;
|
|
} else if (version == 5) {
|
|
status = TRAINLOG_STATUS_OK;
|
|
} else if (version == 6) {
|
|
status = TRAINLOG_STATUS_OK;
|
|
} else {
|
|
char message[128];
|
|
(void)snprintf(message,
|
|
sizeof(message),
|
|
"schema version %d is unsupported (supported through version %d)",
|
|
version,
|
|
TRAINLOG_DATABASE_SCHEMA_VERSION);
|
|
set_application_diagnostic(output_diagnostic, output_diagnostic_capacity, message);
|
|
return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
|
}
|
|
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V4_TO_V5_SQL_A);
|
|
}
|
|
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V4_TO_V5_SQL_B);
|
|
}
|
|
|
|
if (status == TRAINLOG_STATUS_OK && version < 6) {
|
|
status = execute_sql(database, MIGRATE_V5_TO_V6_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V6_TO_V7_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V7_TO_V8_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
|
}
|
|
}
|
|
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
if (version < 11) {
|
|
status = migrate_v10_to_v11(database);
|
|
}
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 12) {
|
|
status = execute_sql(database, MIGRATE_V11_TO_V12_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 13) {
|
|
status = execute_sql(database, MIGRATE_V12_TO_V13_SQL_A);
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V12_TO_V13_SQL_B);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, MIGRATE_V12_TO_V13_SQL_C);
|
|
}
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 14) {
|
|
status = execute_sql(database, MIGRATE_V13_TO_V14_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 15) {
|
|
status = execute_sql(database, MIGRATE_V14_TO_V15_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 16) {
|
|
status = migrate_v15_to_v16(database);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 17) {
|
|
status = execute_sql(database, MIGRATE_V16_TO_V17_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 18) {
|
|
status = execute_sql(database, MIGRATE_V17_TO_V18_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 19) {
|
|
status = execute_sql(database, MIGRATE_V18_TO_V19_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 20) {
|
|
status = execute_sql(database, MIGRATE_V19_TO_V20_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 21) {
|
|
status = execute_sql(database, MIGRATE_V20_TO_V21_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 22) {
|
|
status = execute_sql(database, MIGRATE_V21_TO_V22_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 23) {
|
|
status = execute_sql(database, MIGRATE_V22_TO_V23_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 24) {
|
|
status = execute_sql(database, MIGRATE_V23_TO_V24_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 25) {
|
|
status = execute_sql(database, MIGRATE_V24_TO_V25_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = ensure_v25_preparation_provenance(database);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = ensure_v25_proposal_withdrawal(database);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = ensure_v25_web_deletion_requests(database);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 26) {
|
|
status = execute_sql(database, MIGRATE_V25_TO_V26_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 27) {
|
|
status = execute_sql(database, MIGRATE_V26_TO_V27_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 28) {
|
|
status = execute_sql(database, MIGRATE_V27_TO_V28_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 29) {
|
|
status = execute_sql(database, MIGRATE_V28_TO_V29_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, ENSURE_V29_SLEEP_MEDICATION_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 30) {
|
|
status = execute_sql(database, MIGRATE_V29_TO_V30_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 31) {
|
|
status = execute_sql(database, MIGRATE_V30_TO_V31_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 32) {
|
|
status = ensure_v32_session_kind(database);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 32) {
|
|
status = execute_sql(database, MIGRATE_V31_TO_V32_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 33) {
|
|
status = execute_sql(database, MIGRATE_V32_TO_V33_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 34) {
|
|
status = execute_sql(database, MIGRATE_V33_TO_V34_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 35) {
|
|
status = ensure_v35_medication_quantity(database);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 36) {
|
|
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK && version < 37) {
|
|
status = execute_sql(database, MIGRATE_V36_TO_V37_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = ensure_v18_ai_draft_publication_state(database);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, "BEGIN IMMEDIATE;");
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, ENSURE_V17_PROFILE_REVISIONS_SQL);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = normalize_v17_prototype_profiles(database);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = execute_sql(database, "COMMIT;");
|
|
}
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
set_open_diagnostic(output_diagnostic,
|
|
output_diagnostic_capacity,
|
|
version == 0 ? "create schema v37" : "migrate database to schema v37",
|
|
database->connection,
|
|
SQLITE_ERROR);
|
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
|
/* MIGRATE_V9_TO_V10_SQL disables foreign keys outside its transaction;
|
|
* a failed statement must not leave the live handle with enforcement
|
|
* disabled after the rollback. */
|
|
(void)sqlite3_exec(database->connection, "PRAGMA foreign_keys = ON;", NULL, NULL, NULL);
|
|
}
|
|
|
|
return status;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_open(const char *path, TrainlogDatabase **output_database) {
|
|
return trainlog_database_open_with_diagnostic(path, output_database, NULL, 0U);
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_open_with_diagnostic(const char *path,
|
|
TrainlogDatabase **output_database,
|
|
char *output_diagnostic,
|
|
size_t output_diagnostic_capacity) {
|
|
TrainlogDatabase *database;
|
|
int rc;
|
|
TrainlogStatus status;
|
|
|
|
if (path == NULL || path[0] == '\0' || output_database == NULL) {
|
|
set_open_diagnostic(output_diagnostic,
|
|
output_diagnostic_capacity,
|
|
"validate database path",
|
|
NULL,
|
|
SQLITE_MISUSE);
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
*output_database = NULL;
|
|
if (output_diagnostic != NULL && output_diagnostic_capacity > 0U) {
|
|
output_diagnostic[0] = '\0';
|
|
}
|
|
|
|
database = calloc(1U, sizeof(*database));
|
|
if (database == NULL) {
|
|
set_open_diagnostic(output_diagnostic,
|
|
output_diagnostic_capacity,
|
|
"allocate database handle",
|
|
NULL,
|
|
SQLITE_NOMEM);
|
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_open_v2(path,
|
|
&database->connection,
|
|
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX,
|
|
NULL);
|
|
if (rc != SQLITE_OK) {
|
|
set_open_diagnostic(output_diagnostic,
|
|
output_diagnostic_capacity,
|
|
"open database",
|
|
database->connection,
|
|
rc);
|
|
trainlog_database_close(database);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_create_function(database->connection,
|
|
"trainlog_profile_revision",
|
|
4,
|
|
SQLITE_UTF8 | SQLITE_DETERMINISTIC,
|
|
NULL,
|
|
sqlite_profile_revision,
|
|
NULL,
|
|
NULL) != SQLITE_OK) {
|
|
set_open_diagnostic(output_diagnostic,
|
|
output_diagnostic_capacity,
|
|
"register profile revision function",
|
|
database->connection,
|
|
SQLITE_ERROR);
|
|
trainlog_database_close(database);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (sqlite3_create_function(database->connection,
|
|
"trainlog_pr1_valid",
|
|
4,
|
|
SQLITE_UTF8 | SQLITE_DETERMINISTIC,
|
|
NULL,
|
|
sqlite_prototype_profile_valid,
|
|
NULL,
|
|
NULL) != SQLITE_OK) {
|
|
set_open_diagnostic(output_diagnostic,
|
|
output_diagnostic_capacity,
|
|
"register prototype profile validator",
|
|
database->connection,
|
|
SQLITE_ERROR);
|
|
trainlog_database_close(database);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_busy_timeout(database->connection, 5000) != SQLITE_OK) {
|
|
set_open_diagnostic(output_diagnostic,
|
|
output_diagnostic_capacity,
|
|
"configure database busy timeout",
|
|
database->connection,
|
|
SQLITE_ERROR);
|
|
trainlog_database_close(database);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
status = execute_sql(database, "PRAGMA foreign_keys = ON;");
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
set_open_diagnostic(output_diagnostic,
|
|
output_diagnostic_capacity,
|
|
"enable foreign-key enforcement",
|
|
database->connection,
|
|
SQLITE_ERROR);
|
|
trainlog_database_close(database);
|
|
return status;
|
|
}
|
|
|
|
status = initialize_or_validate_schema(database, output_diagnostic, output_diagnostic_capacity);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
trainlog_database_close(database);
|
|
return status;
|
|
}
|
|
|
|
*output_database = database;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
void trainlog_database_close(TrainlogDatabase *database) {
|
|
if (database == NULL) {
|
|
return;
|
|
}
|
|
|
|
if (database->connection != NULL) {
|
|
(void)sqlite3_close(database->connection);
|
|
}
|
|
|
|
free(database);
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_schema_version(TrainlogDatabase *database, int *output_version) {
|
|
return read_single_int_pragma(database, "PRAGMA user_version;", output_version);
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_foreign_keys_enabled(TrainlogDatabase *database,
|
|
int *output_enabled) {
|
|
return read_single_int_pragma(database, "PRAGMA foreign_keys;", output_enabled);
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_create_custom_equipment(TrainlogDatabase *database,
|
|
const TrainlogCustomEquipment *equipment) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc;
|
|
|
|
/* CONTRACT: custom IDs must not shadow frozen manifest identities. */
|
|
if (database == NULL || equipment == NULL || equipment->equipment_id[0] == '\0' ||
|
|
equipment->display_name[0] == '\0' || equipment->equipment_type[0] == '\0' ||
|
|
trainlog_equipment_catalog_lookup(equipment->equipment_id) != NULL ||
|
|
(strcmp(equipment->load_semantics, "none") != 0 &&
|
|
strcmp(equipment->load_semantics, "external") != 0 &&
|
|
strcmp(equipment->load_semantics, "assistance") != 0)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"INSERT INTO "
|
|
"custom_equipment(equipment_id,display_name,label_name,equipment_type,"
|
|
"load_semantics) VALUES(?1,?2,?3,?4,?5);",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
(void)sqlite3_bind_text(statement, 1, equipment->equipment_id, -1, SQLITE_TRANSIENT);
|
|
(void)sqlite3_bind_text(statement, 2, equipment->display_name, -1, SQLITE_TRANSIENT);
|
|
(void)sqlite3_bind_text(statement, 3, equipment->label_name, -1, SQLITE_TRANSIENT);
|
|
(void)sqlite3_bind_text(statement, 4, equipment->equipment_type, -1, SQLITE_TRANSIENT);
|
|
(void)sqlite3_bind_text(statement, 5, equipment->load_semantics, -1, SQLITE_TRANSIENT);
|
|
rc = sqlite3_step(statement);
|
|
(void)sqlite3_finalize(statement);
|
|
return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_list_custom_equipment(TrainlogDatabase *database,
|
|
TrainlogCustomEquipment *output,
|
|
size_t capacity,
|
|
size_t *output_count) {
|
|
sqlite3_stmt *statement = NULL;
|
|
size_t count = 0U;
|
|
int rc;
|
|
if (database == NULL || output_count == NULL || (capacity > 0U && output == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
*output_count = 0U;
|
|
rc = sqlite3_prepare_v2(
|
|
database->connection,
|
|
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM "
|
|
"custom_equipment ce "
|
|
"WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE s.target_kind='custom_equipment' "
|
|
"AND s.target_id=ce.equipment_id AND s.deleted=1) "
|
|
"ORDER BY display_name COLLATE NOCASE,equipment_id;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
if (count < capacity) {
|
|
TrainlogCustomEquipment *item = &output[count];
|
|
(void)memset(item, 0, sizeof(*item));
|
|
(void)snprintf(item->equipment_id,
|
|
sizeof(item->equipment_id),
|
|
"%s",
|
|
sqlite3_column_text(statement, 0));
|
|
(void)snprintf(item->display_name,
|
|
sizeof(item->display_name),
|
|
"%s",
|
|
sqlite3_column_text(statement, 1));
|
|
(void)snprintf(item->label_name,
|
|
sizeof(item->label_name),
|
|
"%s",
|
|
sqlite3_column_text(statement, 2));
|
|
(void)snprintf(item->equipment_type,
|
|
sizeof(item->equipment_type),
|
|
"%s",
|
|
sqlite3_column_text(statement, 3));
|
|
(void)snprintf(item->load_semantics,
|
|
sizeof(item->load_semantics),
|
|
"%s",
|
|
sqlite3_column_text(statement, 4));
|
|
}
|
|
++count;
|
|
}
|
|
(void)sqlite3_finalize(statement);
|
|
*output_count = count;
|
|
return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static bool copy_custom_equipment_page_column(sqlite3_stmt *statement,
|
|
int column,
|
|
char *output,
|
|
size_t capacity) {
|
|
const unsigned char *source;
|
|
int byte_count;
|
|
size_t length;
|
|
|
|
if (sqlite3_column_type(statement, column) != SQLITE_TEXT || output == NULL || capacity == 0U) {
|
|
return false;
|
|
}
|
|
source = sqlite3_column_text(statement, column);
|
|
byte_count = sqlite3_column_bytes(statement, column);
|
|
if (source == NULL || byte_count < 0) {
|
|
return false;
|
|
}
|
|
length = (size_t)byte_count;
|
|
/* SQLite TEXT may contain embedded NUL bytes; never silently truncate it. */
|
|
if (length >= capacity || memchr(source, '\0', length) != NULL) {
|
|
return false;
|
|
}
|
|
(void)memcpy(output, source, length);
|
|
output[length] = '\0';
|
|
return true;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_list_custom_equipment_page(TrainlogDatabase *database,
|
|
size_t offset,
|
|
TrainlogCustomEquipment *output,
|
|
size_t capacity,
|
|
size_t *output_count,
|
|
bool *output_more) {
|
|
static const char *const SQL =
|
|
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
|
"FROM custom_equipment ce WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE "
|
|
"s.target_kind='custom_equipment' AND s.target_id=ce.equipment_id AND s.deleted=1) ORDER "
|
|
"BY display_name COLLATE NOCASE,equipment_id "
|
|
"LIMIT ?1 OFFSET ?2;";
|
|
sqlite3_stmt *statement = NULL;
|
|
size_t count = 0U;
|
|
int rc;
|
|
|
|
if (output != NULL && output_count != NULL && output_more != NULL && capacity >= 1U &&
|
|
capacity <= TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX) {
|
|
*output_count = 0U;
|
|
*output_more = false;
|
|
}
|
|
if (database == NULL || output == NULL || output_count == NULL || output_more == NULL ||
|
|
capacity == 0U || capacity > TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX ||
|
|
(uintmax_t)offset > (uintmax_t)INT64_MAX) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (sqlite3_bind_int64(statement, 1, (sqlite3_int64)(capacity + 1U)) != SQLITE_OK ||
|
|
sqlite3_bind_int64(statement, 2, (sqlite3_int64)offset) != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
TrainlogCustomEquipment item;
|
|
(void)memset(&item, 0, sizeof(item));
|
|
if (!copy_custom_equipment_page_column(
|
|
statement, 0, item.equipment_id, sizeof(item.equipment_id)) ||
|
|
!copy_custom_equipment_page_column(
|
|
statement, 1, item.display_name, sizeof(item.display_name)) ||
|
|
!copy_custom_equipment_page_column(
|
|
statement, 2, item.label_name, sizeof(item.label_name)) ||
|
|
!copy_custom_equipment_page_column(
|
|
statement, 3, item.equipment_type, sizeof(item.equipment_type)) ||
|
|
!copy_custom_equipment_page_column(
|
|
statement, 4, item.load_semantics, sizeof(item.load_semantics))) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (count == capacity) {
|
|
*output_more = true;
|
|
break;
|
|
}
|
|
output[count++] = item;
|
|
}
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (rc != SQLITE_DONE && rc != SQLITE_ROW) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
*output_count = count;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_resolve_equipment(TrainlogDatabase *database,
|
|
const char *equipment_id,
|
|
TrainlogResolvedEquipment *output) {
|
|
const TrainlogEquipment *supplied;
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc;
|
|
if (database == NULL || equipment_id == NULL || equipment_id[0] == '\0' || output == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
(void)memset(output, 0, sizeof(*output));
|
|
(void)snprintf(output->equipment_id, sizeof(output->equipment_id), "%s", equipment_id);
|
|
supplied = trainlog_equipment_catalog_lookup(equipment_id);
|
|
if (supplied != NULL) {
|
|
(void)snprintf(
|
|
output->display_name, sizeof(output->display_name), "%s", supplied->display_name);
|
|
(void)snprintf(output->label_name, sizeof(output->label_name), "%s", supplied->label_name);
|
|
(void)snprintf(
|
|
output->equipment_type, sizeof(output->equipment_type), "%s", supplied->equipment_type);
|
|
(void)snprintf(
|
|
output->load_semantics, sizeof(output->load_semantics), "%s", supplied->load_semantics);
|
|
output->origin = TRAINLOG_EQUIPMENT_SUPPLIED;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"SELECT display_name,label_name,equipment_type,load_semantics FROM "
|
|
"custom_equipment WHERE equipment_id=?1;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
(void)sqlite3_bind_text(statement, 1, equipment_id, -1, SQLITE_TRANSIENT);
|
|
rc = sqlite3_step(statement);
|
|
if (rc == SQLITE_ROW) {
|
|
(void)snprintf(output->display_name,
|
|
sizeof(output->display_name),
|
|
"%s",
|
|
sqlite3_column_text(statement, 0));
|
|
(void)snprintf(output->label_name,
|
|
sizeof(output->label_name),
|
|
"%s",
|
|
sqlite3_column_text(statement, 1));
|
|
(void)snprintf(output->equipment_type,
|
|
sizeof(output->equipment_type),
|
|
"%s",
|
|
sqlite3_column_text(statement, 2));
|
|
(void)snprintf(output->load_semantics,
|
|
sizeof(output->load_semantics),
|
|
"%s",
|
|
sqlite3_column_text(statement, 3));
|
|
output->origin = TRAINLOG_EQUIPMENT_CUSTOM;
|
|
} else if (rc == SQLITE_DONE) {
|
|
/* INVARIANT: historic references survive catalogue changes visibly. */
|
|
(void)snprintf(
|
|
output->display_name, sizeof(output->display_name), "Inconnu (%s)", equipment_id);
|
|
output->origin = TRAINLOG_EQUIPMENT_UNKNOWN;
|
|
rc = SQLITE_ROW;
|
|
}
|
|
(void)sqlite3_finalize(statement);
|
|
return rc == SQLITE_ROW ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_list_exercise_equipment(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
TrainlogResolvedEquipment *output,
|
|
size_t capacity,
|
|
size_t *output_count) {
|
|
sqlite3_stmt *statement = NULL;
|
|
size_t count = 0U;
|
|
int rc;
|
|
if (database == NULL || exercise_id == NULL || output_count == NULL ||
|
|
(capacity > 0U && output == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
*output_count = 0U;
|
|
rc = sqlite3_prepare_v2(
|
|
database->connection,
|
|
"SELECT DISTINCT se.equipment_id FROM session_exercises se JOIN exercises e ON "
|
|
"e.id=se.exercise_row_id WHERE e.exercise_id=?1 AND se.equipment_id IS NOT NULL AND "
|
|
"se.equipment_id<>'' ORDER BY se.equipment_id;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
(void)sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
const char *id = (const char *)sqlite3_column_text(statement, 0);
|
|
if (count < capacity && trainlog_database_resolve_equipment(database, id, &output[count]) !=
|
|
TRAINLOG_STATUS_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
++count;
|
|
}
|
|
(void)sqlite3_finalize(statement);
|
|
*output_count = count;
|
|
return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_begin(TrainlogDatabase *database) {
|
|
return execute_sql(database, "BEGIN IMMEDIATE;");
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_commit(TrainlogDatabase *database) {
|
|
return execute_sql(database, "COMMIT;");
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_rollback(TrainlogDatabase *database) {
|
|
return execute_sql(database, "ROLLBACK;");
|
|
}
|
|
|
|
static const char *tracking_mode_to_sql(TrainlogTrackingMode mode) {
|
|
switch (mode) {
|
|
case TRAINLOG_TRACKING_REPS:
|
|
return "reps";
|
|
case TRAINLOG_TRACKING_DURATION:
|
|
return "duration";
|
|
default:
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
static const char *recording_mode_to_sql(TrainlogRecordingMode mode) {
|
|
switch (mode) {
|
|
case TRAINLOG_RECORDING_SETS:
|
|
return "sets";
|
|
case TRAINLOG_RECORDING_CONTINUOUS:
|
|
return "continuous";
|
|
default:
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
static bool exercise_profile_valid(TrainlogTrackingMode tracking_mode,
|
|
TrainlogRecordingMode recording_mode,
|
|
TrainlogExerciseDataFields data_fields) {
|
|
if ((data_fields & ~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) != 0U) {
|
|
return false;
|
|
}
|
|
|
|
if (recording_mode == TRAINLOG_RECORDING_CONTINUOUS) {
|
|
return tracking_mode == TRAINLOG_TRACKING_DURATION;
|
|
}
|
|
|
|
return recording_mode == TRAINLOG_RECORDING_SETS &&
|
|
(tracking_mode == TRAINLOG_TRACKING_REPS || tracking_mode == TRAINLOG_TRACKING_DURATION);
|
|
}
|
|
|
|
static const char *load_mode_to_sql(TrainlogLoadMode mode) {
|
|
switch (mode) {
|
|
case TRAINLOG_LOAD_NONE:
|
|
return "none";
|
|
case TRAINLOG_LOAD_EXTERNAL:
|
|
return "external";
|
|
case TRAINLOG_LOAD_ASSISTANCE:
|
|
return "assistance";
|
|
default:
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
static const char *session_type_to_sql(TrainlogSessionType type) {
|
|
switch (type) {
|
|
case TRAINLOG_SESSION_TRAINING:
|
|
return "training";
|
|
|
|
case TRAINLOG_SESSION_MAX_TEST:
|
|
return "max_test";
|
|
|
|
case TRAINLOG_SESSION_CARDIO:
|
|
return "cardio";
|
|
|
|
default:
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_insert_exercise_profiled(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
const char *name,
|
|
const char *normalized_name,
|
|
TrainlogTrackingMode tracking_mode,
|
|
TrainlogRecordingMode recording_mode,
|
|
TrainlogExerciseDataFields data_fields) {
|
|
static const char *const SQL = "INSERT INTO exercises("
|
|
"exercise_id, name, normalized_name, tracking_mode, "
|
|
"recording_mode, data_fields"
|
|
") VALUES(?1, ?2, ?3, ?4, ?5, ?6);";
|
|
|
|
sqlite3_stmt *statement = NULL;
|
|
const char *tracking;
|
|
const char *recording;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || exercise_id == NULL ||
|
|
exercise_id[0] == '\0' || name == NULL || name[0] == '\0' || normalized_name == NULL ||
|
|
normalized_name[0] == '\0' ||
|
|
!exercise_profile_valid(tracking_mode, recording_mode, data_fields)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
tracking = tracking_mode_to_sql(tracking_mode);
|
|
recording = recording_mode_to_sql(recording_mode);
|
|
|
|
if (tracking == NULL || recording == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
|
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 2, name, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 3, normalized_name, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 4, tracking, -1, SQLITE_STATIC) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 5, recording, -1, SQLITE_STATIC) != SQLITE_OK ||
|
|
sqlite3_bind_int64(statement, 6, (sqlite3_int64)data_fields) != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
|
|
if (rc == SQLITE_CONSTRAINT) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_CONFLICT;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_insert_exercise(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
const char *name,
|
|
const char *normalized_name,
|
|
TrainlogTrackingMode tracking_mode) {
|
|
return trainlog_database_insert_exercise_profiled(
|
|
database, exercise_id, name, normalized_name, tracking_mode, TRAINLOG_RECORDING_SETS, 0U);
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_update_exercise_profiled(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
const char *name,
|
|
const char *normalized_name,
|
|
TrainlogTrackingMode tracking_mode,
|
|
TrainlogRecordingMode recording_mode,
|
|
TrainlogExerciseDataFields data_fields,
|
|
const char *primary_zone_id,
|
|
const char *const *secondary_zone_ids,
|
|
size_t secondary_count) {
|
|
sqlite3_stmt *statement = NULL;
|
|
sqlite3_int64 row_id;
|
|
TrainlogStatus status;
|
|
bool owns_transaction;
|
|
int rc;
|
|
const char *tracking;
|
|
const char *recording;
|
|
|
|
if (database == NULL || exercise_id == NULL || name == NULL || normalized_name == NULL ||
|
|
name[0] == '\0' || normalized_name[0] == '\0' || strlen(name) > TRAINLOG_NAME_MAX ||
|
|
!exercise_profile_valid(tracking_mode, recording_mode, data_fields)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
tracking = tracking_mode_to_sql(tracking_mode);
|
|
recording = recording_mode_to_sql(recording_mode);
|
|
status = lookup_exercise_row_id(database, exercise_id, &row_id);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
owns_transaction = sqlite3_get_autocommit(database->connection) != 0;
|
|
if (owns_transaction && execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
/* CONTRACT: schema v16 makes tracking semantics occurrence-owned. A
|
|
* catalogue profile update therefore controls future occurrences only;
|
|
* this transaction never updates session_exercises snapshots. */
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"UPDATE exercises SET name=?1,normalized_name=?2,tracking_mode=?3,"
|
|
"recording_mode=?4,data_fields=?5 WHERE id=?6;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 1, name, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 2, normalized_name, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 3, tracking, -1, SQLITE_STATIC) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 4, recording, -1, SQLITE_STATIC) != SQLITE_OK ||
|
|
sqlite3_bind_int64(statement, 5, (sqlite3_int64)data_fields) != SQLITE_OK ||
|
|
sqlite3_bind_int64(statement, 6, row_id) != SQLITE_OK) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto rollback;
|
|
}
|
|
rc = sqlite3_step(statement);
|
|
if (rc != SQLITE_DONE) {
|
|
status =
|
|
rc == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto rollback;
|
|
}
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
statement = NULL;
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto rollback;
|
|
}
|
|
statement = NULL;
|
|
status = trainlog_database_replace_exercise_body_zones(
|
|
database, exercise_id, primary_zone_id, secondary_zone_ids, secondary_count);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto rollback;
|
|
}
|
|
if (!owns_transaction) {
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) {
|
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
|
|
rollback:
|
|
if (statement != NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
}
|
|
if (owns_transaction) {
|
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
|
}
|
|
return status;
|
|
}
|
|
|
|
static TrainlogStatus
|
|
count_query(TrainlogDatabase *database, const char *sql, size_t *output_count) {
|
|
sqlite3_stmt *statement = NULL;
|
|
sqlite3_int64 count;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || sql == NULL || output_count == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
if (rc != SQLITE_ROW) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
count = sqlite3_column_int64(statement, 0);
|
|
if (count < 0 || (uint64_t)count > (uint64_t)SIZE_MAX) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
*output_count = (size_t)count;
|
|
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_exercise_count(TrainlogDatabase *database, size_t *output_count) {
|
|
return count_query(database, "SELECT COUNT(*) FROM exercises;", output_count);
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_session_count(TrainlogDatabase *database, size_t *output_count) {
|
|
return count_query(database, "SELECT COUNT(*) FROM sessions;", output_count);
|
|
}
|
|
|
|
static TrainlogTrackingMode tracking_mode_from_sql(const char *text) {
|
|
return text != NULL && strcmp(text, "duration") == 0 ? TRAINLOG_TRACKING_DURATION
|
|
: TRAINLOG_TRACKING_REPS;
|
|
}
|
|
|
|
static TrainlogRecordingMode recording_mode_from_sql(const char *text) {
|
|
return text != NULL && strcmp(text, "continuous") == 0 ? TRAINLOG_RECORDING_CONTINUOUS
|
|
: TRAINLOG_RECORDING_SETS;
|
|
}
|
|
|
|
static bool session_type_from_sql(const char *text, TrainlogSessionType *output) {
|
|
if (text == NULL || output == NULL) {
|
|
return false;
|
|
}
|
|
|
|
if (strcmp(text, "training") == 0) {
|
|
*output = TRAINLOG_SESSION_TRAINING;
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(text, "max_test") == 0) {
|
|
*output = TRAINLOG_SESSION_MAX_TEST;
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(text, "cardio") == 0) {
|
|
*output = TRAINLOG_SESSION_CARDIO;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_list_exercises(TrainlogDatabase *database,
|
|
TrainlogExercise *output,
|
|
size_t capacity,
|
|
size_t *output_count) {
|
|
static const char *const SQL = "SELECT exercise_id, name, tracking_mode, "
|
|
"recording_mode, data_fields "
|
|
"FROM exercises "
|
|
"ORDER BY name COLLATE NOCASE, exercise_id;";
|
|
|
|
sqlite3_stmt *statement = NULL;
|
|
size_t count = 0U;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || output_count == NULL ||
|
|
(output == NULL && capacity != 0U)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
*output_count = 0U;
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
|
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
if (output != NULL && count < capacity) {
|
|
const unsigned char *exercise_id = sqlite3_column_text(statement, 0);
|
|
const unsigned char *name = sqlite3_column_text(statement, 1);
|
|
const unsigned char *tracking = sqlite3_column_text(statement, 2);
|
|
const unsigned char *recording = sqlite3_column_text(statement, 3);
|
|
sqlite3_int64 data_fields = sqlite3_column_int64(statement, 4);
|
|
|
|
if (exercise_id == NULL || name == NULL || tracking == NULL || recording == NULL ||
|
|
data_fields < 0 || (uint64_t)data_fields > (uint64_t)UINT32_MAX ||
|
|
(((TrainlogExerciseDataFields)data_fields) & ~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) !=
|
|
0U) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
(void)memset(&output[count], 0, sizeof(output[count]));
|
|
|
|
(void)snprintf(output[count].exercise_id,
|
|
sizeof(output[count].exercise_id),
|
|
"%s",
|
|
(const char *)exercise_id);
|
|
|
|
(void)snprintf(
|
|
output[count].name, sizeof(output[count].name), "%s", (const char *)name);
|
|
|
|
output[count].tracking_mode = tracking_mode_from_sql((const char *)tracking);
|
|
|
|
output[count].recording_mode = recording_mode_from_sql((const char *)recording);
|
|
|
|
output[count].data_fields = (TrainlogExerciseDataFields)data_fields;
|
|
}
|
|
|
|
++count;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
*output_count = count;
|
|
|
|
if (output != NULL && count > capacity) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_resolve_exercise_id(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
char *output_canonical_id,
|
|
size_t output_capacity) {
|
|
static const char *const SQL = "SELECT exercise_id FROM exercises WHERE exercise_id=?1 "
|
|
"UNION ALL SELECT canonical_exercise_id FROM exercise_aliases "
|
|
"WHERE source_exercise_id=?1 LIMIT 1;";
|
|
sqlite3_stmt *statement = NULL;
|
|
const unsigned char *canonical;
|
|
int rc;
|
|
if (database == NULL || database->connection == NULL || exercise_id == NULL ||
|
|
exercise_id[0] == '\0' || output_canonical_id == NULL ||
|
|
output_capacity < TRAINLOG_ID_MAX + 1U) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
|
if (statement != NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
}
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
rc = sqlite3_step(statement);
|
|
if (rc == SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_NOT_FOUND;
|
|
}
|
|
canonical = rc == SQLITE_ROW ? sqlite3_column_text(statement, 0) : NULL;
|
|
if (canonical == NULL || strlen((const char *)canonical) > TRAINLOG_ID_MAX) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
(void)snprintf(output_canonical_id, output_capacity, "%s", canonical);
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static TrainlogStatus merge_bind_ids(TrainlogDatabase *database,
|
|
const char *sql,
|
|
sqlite3_int64 source_row_id,
|
|
sqlite3_int64 canonical_row_id) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK || sqlite3_bind_parameter_count(statement) < 1 ||
|
|
sqlite3_bind_int64(statement, 1, source_row_id) != SQLITE_OK ||
|
|
(sqlite3_bind_parameter_count(statement) >= 2 &&
|
|
sqlite3_bind_int64(statement, 2, canonical_row_id) != SQLITE_OK)) {
|
|
if (statement != NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
}
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
rc = sqlite3_step(statement);
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return rc == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_merge_exercises(TrainlogDatabase *database,
|
|
const char *source_exercise_id,
|
|
const char *canonical_exercise_id) {
|
|
static const char *const PROFILE_SQL =
|
|
"SELECT s.id,c.id,s.tracking_mode,c.tracking_mode,"
|
|
"s.recording_mode,c.recording_mode,s.data_fields,c.data_fields,"
|
|
"(SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=s.id "
|
|
"AND role='primary'),(SELECT zone_id FROM exercise_body_zones "
|
|
"WHERE exercise_row_id=c.id AND role='primary') "
|
|
"FROM exercises s JOIN exercises c ON s.exercise_id=?1 "
|
|
"AND c.exercise_id=?2;";
|
|
sqlite3_stmt *statement = NULL;
|
|
sqlite3_int64 source_row_id, canonical_row_id;
|
|
const char *source_primary = NULL, *canonical_primary = NULL;
|
|
char primary[TRAINLOG_ZONE_ID_MAX + 1U] = "";
|
|
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
int rc;
|
|
if (database == NULL || database->connection == NULL || source_exercise_id == NULL ||
|
|
canonical_exercise_id == NULL || source_exercise_id[0] == '\0' ||
|
|
canonical_exercise_id[0] == '\0' ||
|
|
strcmp(source_exercise_id, canonical_exercise_id) == 0) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
rc = sqlite3_prepare_v2(database->connection, PROFILE_SQL, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 1, source_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 2, canonical_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
|
goto rollback;
|
|
}
|
|
rc = sqlite3_step(statement);
|
|
if (rc == SQLITE_DONE) {
|
|
status = TRAINLOG_STATUS_NOT_FOUND;
|
|
goto rollback;
|
|
}
|
|
if (rc != SQLITE_ROW) {
|
|
goto rollback;
|
|
}
|
|
source_row_id = sqlite3_column_int64(statement, 0);
|
|
canonical_row_id = sqlite3_column_int64(statement, 1);
|
|
if (strcmp((const char *)sqlite3_column_text(statement, 2),
|
|
(const char *)sqlite3_column_text(statement, 3)) != 0 ||
|
|
strcmp((const char *)sqlite3_column_text(statement, 4),
|
|
(const char *)sqlite3_column_text(statement, 5)) != 0 ||
|
|
sqlite3_column_int64(statement, 6) != sqlite3_column_int64(statement, 7)) {
|
|
status = TRAINLOG_STATUS_CONFLICT;
|
|
goto rollback;
|
|
}
|
|
if (sqlite3_column_type(statement, 8) != SQLITE_NULL) {
|
|
source_primary = (const char *)sqlite3_column_text(statement, 8);
|
|
}
|
|
if (sqlite3_column_type(statement, 9) != SQLITE_NULL) {
|
|
canonical_primary = (const char *)sqlite3_column_text(statement, 9);
|
|
}
|
|
if (source_primary != NULL && canonical_primary != NULL &&
|
|
strcmp(source_primary, canonical_primary) != 0) {
|
|
status = TRAINLOG_STATUS_CONFLICT;
|
|
goto rollback;
|
|
}
|
|
if (canonical_primary != NULL || source_primary != NULL) {
|
|
(void)snprintf(primary,
|
|
sizeof(primary),
|
|
"%s",
|
|
canonical_primary != NULL ? canonical_primary : source_primary);
|
|
}
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
statement = NULL;
|
|
goto rollback;
|
|
}
|
|
statement = NULL;
|
|
|
|
/* INVARIANT: promote the chosen primary before unioning secondaries, so a
|
|
* former secondary cannot mask it through the (exercise,zone) key. */
|
|
if (primary[0] != '\0') {
|
|
status = merge_bind_ids(database,
|
|
"DELETE FROM exercise_body_zones WHERE exercise_row_id=?2 "
|
|
"AND zone_id=(SELECT zone_id FROM exercise_body_zones "
|
|
"WHERE exercise_row_id=?1 AND role='primary');",
|
|
source_row_id,
|
|
canonical_row_id);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto rollback;
|
|
}
|
|
rc = sqlite3_prepare_v2(
|
|
database->connection,
|
|
"INSERT OR REPLACE INTO exercise_body_zones(exercise_row_id,zone_id,role) "
|
|
"VALUES(?1,?2,'primary');",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, canonical_row_id) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 2, primary, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
|
statement = NULL;
|
|
goto rollback;
|
|
}
|
|
statement = NULL;
|
|
}
|
|
status =
|
|
merge_bind_ids(database,
|
|
"INSERT OR IGNORE INTO exercise_body_zones(exercise_row_id,zone_id,role) "
|
|
"SELECT ?2,zone_id,'secondary' FROM exercise_body_zones "
|
|
"WHERE exercise_row_id=?1 AND role='secondary' "
|
|
"AND zone_id<>(SELECT COALESCE((SELECT zone_id FROM exercise_body_zones "
|
|
"WHERE exercise_row_id=?2 AND role='primary'),''));",
|
|
source_row_id,
|
|
canonical_row_id);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto rollback;
|
|
}
|
|
status =
|
|
merge_bind_ids(database,
|
|
"UPDATE session_exercises SET exercise_row_id=?2 WHERE exercise_row_id=?1;",
|
|
source_row_id,
|
|
canonical_row_id);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto rollback;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"UPDATE exercise_aliases SET canonical_exercise_id=?2 "
|
|
"WHERE canonical_exercise_id=?1;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 1, source_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 2, canonical_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
|
statement = NULL;
|
|
goto rollback;
|
|
}
|
|
statement = NULL;
|
|
rc =
|
|
sqlite3_prepare_v2(database->connection,
|
|
"INSERT INTO exercise_aliases(source_exercise_id,canonical_exercise_id) "
|
|
"VALUES(?1,?2);",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 1, source_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 2, canonical_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
|
statement = NULL;
|
|
goto rollback;
|
|
}
|
|
statement = NULL;
|
|
status = merge_bind_ids(database,
|
|
"DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?1,?2);",
|
|
source_row_id,
|
|
canonical_row_id);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto rollback;
|
|
}
|
|
status = merge_bind_ids(
|
|
database, "DELETE FROM exercises WHERE id=?1;", source_row_id, canonical_row_id);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto rollback;
|
|
}
|
|
if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) {
|
|
goto rollback;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
|
|
rollback:
|
|
if (statement != NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
}
|
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
|
return status;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_preview_exercise_merge(TrainlogDatabase *database,
|
|
const char *source_exercise_id,
|
|
TrainlogExerciseMergePreview *output) {
|
|
static const char *const SQL =
|
|
"SELECT "
|
|
"(SELECT COUNT(*) FROM session_exercises se WHERE se.exercise_row_id=e.id),"
|
|
"(SELECT COUNT(*) FROM performed_sets ps JOIN session_exercises se "
|
|
"ON se.id=ps.session_exercise_row_id WHERE se.exercise_row_id=e.id),"
|
|
"(SELECT COUNT(*) FROM continuous_activity ca JOIN session_exercises se "
|
|
"ON se.id=ca.session_exercise_row_id WHERE se.exercise_row_id=e.id),"
|
|
"(SELECT COUNT(*) FROM max_results mr JOIN session_exercises se "
|
|
"ON se.id=mr.session_exercise_row_id WHERE se.exercise_row_id=e.id),"
|
|
"(SELECT COUNT(DISTINCT se.equipment_id) FROM session_exercises se "
|
|
"WHERE se.exercise_row_id=e.id AND se.equipment_id IS NOT NULL "
|
|
"AND se.equipment_id<>''),"
|
|
"(SELECT COUNT(*) FROM exercise_body_zones z WHERE z.exercise_row_id=e.id) "
|
|
"FROM exercises e WHERE e.exercise_id=?1;";
|
|
sqlite3_stmt *statement = NULL;
|
|
TrainlogExerciseMergePreview preview = {0};
|
|
int rc;
|
|
if (database == NULL || database->connection == NULL || source_exercise_id == NULL ||
|
|
source_exercise_id[0] == '\0' || output == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 1, source_exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
|
if (statement != NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
}
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
rc = sqlite3_step(statement);
|
|
if (rc == SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_NOT_FOUND;
|
|
}
|
|
if (rc != SQLITE_ROW) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
preview.occurrences = (size_t)sqlite3_column_int64(statement, 0);
|
|
preview.performed_sets = (size_t)sqlite3_column_int64(statement, 1);
|
|
preview.continuous_activities = (size_t)sqlite3_column_int64(statement, 2);
|
|
preview.max_results = (size_t)sqlite3_column_int64(statement, 3);
|
|
preview.associated_equipment = (size_t)sqlite3_column_int64(statement, 4);
|
|
preview.body_zones = (size_t)sqlite3_column_int64(statement, 5);
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
*output = preview;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static TrainlogStatus lookup_exercise_row_id(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
sqlite3_int64 *output_row_id) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc;
|
|
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"SELECT id FROM exercises WHERE exercise_id = ?1;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
if (rc == SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_NOT_FOUND;
|
|
}
|
|
if (rc != SQLITE_ROW) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
*output_row_id = sqlite3_column_int64(statement, 0);
|
|
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static bool assignable_body_zone(const char *zone_id) {
|
|
const TrainlogBodyZone *zone;
|
|
if (zone_id == NULL || zone_id[0] == '\0' || strlen(zone_id) > TRAINLOG_ZONE_ID_MAX) {
|
|
return false;
|
|
}
|
|
zone = trainlog_body_zone_catalog_lookup(zone_id);
|
|
return zone != NULL && !zone->is_group;
|
|
}
|
|
|
|
static TrainlogStatus insert_body_zone_relation(TrainlogDatabase *database,
|
|
sqlite3_int64 exercise_row_id,
|
|
const char *zone_id,
|
|
const char *role) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc = sqlite3_prepare_v2(database->connection,
|
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) "
|
|
"VALUES(?1,?2,?3);",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (sqlite3_bind_int64(statement, 1, exercise_row_id) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 2, zone_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_text(statement, 3, role, -1, SQLITE_STATIC) != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
rc = sqlite3_step(statement);
|
|
(void)sqlite3_finalize(statement);
|
|
return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK
|
|
: rc == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_replace_exercise_body_zones(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
const char *primary_zone_id,
|
|
const char *const *secondary_zone_ids,
|
|
size_t secondary_count) {
|
|
sqlite3_int64 row_id;
|
|
sqlite3_stmt *statement = NULL;
|
|
bool owns_transaction;
|
|
size_t index;
|
|
TrainlogStatus status;
|
|
|
|
if (database == NULL || database->connection == NULL || exercise_id == NULL ||
|
|
exercise_id[0] == '\0' || secondary_count > trainlog_body_zone_catalog_count() ||
|
|
(secondary_count > 0U && secondary_zone_ids == NULL) ||
|
|
(secondary_count > 0U && (primary_zone_id == NULL || primary_zone_id[0] == '\0')) ||
|
|
(primary_zone_id != NULL && primary_zone_id[0] != '\0' &&
|
|
!assignable_body_zone(primary_zone_id))) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
for (index = 0U; index < secondary_count; ++index) {
|
|
size_t earlier;
|
|
if (!assignable_body_zone(secondary_zone_ids[index]) ||
|
|
(primary_zone_id != NULL && strcmp(primary_zone_id, secondary_zone_ids[index]) == 0)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
for (earlier = 0U; earlier < index; ++earlier) {
|
|
if (strcmp(secondary_zone_ids[earlier], secondary_zone_ids[index]) == 0) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
}
|
|
}
|
|
status = lookup_exercise_row_id(database, exercise_id, &row_id);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
|
|
owns_transaction = sqlite3_get_autocommit(database->connection) != 0;
|
|
if (owns_transaction && execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (sqlite3_prepare_v2(database->connection,
|
|
"DELETE FROM exercise_body_zones WHERE exercise_row_id=?1;",
|
|
-1,
|
|
&statement,
|
|
NULL) != SQLITE_OK ||
|
|
sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK ||
|
|
sqlite3_step(statement) != SQLITE_DONE) {
|
|
if (statement != NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
}
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto finish;
|
|
}
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
statement = NULL;
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto finish;
|
|
}
|
|
statement = NULL;
|
|
if (primary_zone_id != NULL && primary_zone_id[0] != '\0') {
|
|
status = insert_body_zone_relation(database, row_id, primary_zone_id, "primary");
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto finish;
|
|
}
|
|
}
|
|
for (index = 0U; index < secondary_count; ++index) {
|
|
status =
|
|
insert_body_zone_relation(database, row_id, secondary_zone_ids[index], "secondary");
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto finish;
|
|
}
|
|
}
|
|
status = TRAINLOG_STATUS_OK;
|
|
|
|
finish:
|
|
if (owns_transaction) {
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
|
}
|
|
} else {
|
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
|
}
|
|
}
|
|
return status;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_list_exercise_body_zones(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
TrainlogExerciseBodyZone *output,
|
|
size_t capacity,
|
|
size_t *output_count) {
|
|
sqlite3_stmt *statement = NULL;
|
|
sqlite3_int64 row_id;
|
|
size_t count = 0U;
|
|
bool has_primary = false;
|
|
TrainlogStatus status;
|
|
int rc;
|
|
if (database == NULL || exercise_id == NULL || output_count == NULL ||
|
|
(capacity > 0U && output == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
*output_count = 0U;
|
|
status = lookup_exercise_row_id(database, exercise_id, &row_id);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"SELECT zone_id,role FROM exercise_body_zones WHERE exercise_row_id=?1 "
|
|
"ORDER BY CASE role WHEN 'primary' THEN 0 ELSE 1 END,zone_id;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK) {
|
|
if (statement != NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
}
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
const char *zone_id = (const char *)sqlite3_column_text(statement, 0);
|
|
const char *role = (const char *)sqlite3_column_text(statement, 1);
|
|
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(zone_id);
|
|
if (zone_id == NULL || role == NULL || zone == NULL || zone->is_group ||
|
|
(strcmp(role, "primary") != 0 && strcmp(role, "secondary") != 0)) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (count < capacity) {
|
|
(void)memset(&output[count], 0, sizeof(output[count]));
|
|
(void)snprintf(output[count].zone_id, sizeof(output[count].zone_id), "%s", zone_id);
|
|
output[count].role = strcmp(role, "primary") == 0 ? TRAINLOG_BODY_ZONE_PRIMARY
|
|
: TRAINLOG_BODY_ZONE_SECONDARY;
|
|
}
|
|
if (strcmp(role, "primary") == 0) {
|
|
has_primary = true;
|
|
}
|
|
++count;
|
|
}
|
|
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
/* INVARIANT: the explicitly unclassified state has no relations. A
|
|
* secondary-only raw SQLite state is corruption, not partial metadata. */
|
|
if (count > 0U && !has_primary) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
*output_count = count;
|
|
return count > capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static bool exercise_row_matches_body_zone(TrainlogDatabase *database,
|
|
sqlite3_int64 row_id,
|
|
const char *zone_id,
|
|
bool include_descendants,
|
|
bool primary_only,
|
|
bool unclassified_only,
|
|
bool *output_match) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc;
|
|
bool has_relation = false;
|
|
bool has_primary = false;
|
|
bool match = false;
|
|
if (sqlite3_prepare_v2(database->connection,
|
|
"SELECT zone_id,role FROM exercise_body_zones WHERE exercise_row_id=?1;",
|
|
-1,
|
|
&statement,
|
|
NULL) != SQLITE_OK ||
|
|
sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK) {
|
|
if (statement != NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
}
|
|
return false;
|
|
}
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
const char *candidate = (const char *)sqlite3_column_text(statement, 0);
|
|
const char *role = (const char *)sqlite3_column_text(statement, 1);
|
|
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(candidate);
|
|
if (candidate == NULL || role == NULL || zone == NULL || zone->is_group ||
|
|
(strcmp(role, "primary") != 0 && strcmp(role, "secondary") != 0)) {
|
|
(void)sqlite3_finalize(statement);
|
|
return false;
|
|
}
|
|
has_relation = true;
|
|
if (strcmp(role, "primary") == 0) {
|
|
has_primary = true;
|
|
}
|
|
if (!unclassified_only && (!primary_only || strcmp(role, "primary") == 0) &&
|
|
(strcmp(candidate, zone_id) == 0 ||
|
|
(include_descendants &&
|
|
trainlog_body_zone_catalog_is_descendant(candidate, zone_id)))) {
|
|
match = true;
|
|
}
|
|
}
|
|
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK ||
|
|
(has_relation && !has_primary)) {
|
|
return false;
|
|
}
|
|
*output_match = unclassified_only ? !has_relation : match;
|
|
return true;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_list_exercises_filtered(TrainlogDatabase *database,
|
|
const char *normalized_prefix,
|
|
const char *zone_id,
|
|
bool include_descendants,
|
|
bool primary_only,
|
|
bool unclassified_only,
|
|
TrainlogExercise *output,
|
|
size_t capacity,
|
|
size_t *output_count) {
|
|
static const char *const SQL =
|
|
"SELECT id,exercise_id,name,tracking_mode,recording_mode,data_fields,normalized_name "
|
|
"FROM exercises e WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state s WHERE "
|
|
"s.target_kind='exercise' AND s.target_id=e.exercise_id AND s.deleted=1) ORDER BY name "
|
|
"COLLATE NOCASE,exercise_id;";
|
|
sqlite3_stmt *statement = NULL;
|
|
size_t count = 0U;
|
|
size_t prefix_length;
|
|
int rc;
|
|
if (database == NULL || normalized_prefix == NULL || output_count == NULL ||
|
|
(capacity > 0U && output == NULL) || (unclassified_only && zone_id != NULL) ||
|
|
(!unclassified_only && zone_id != NULL &&
|
|
trainlog_body_zone_catalog_lookup(zone_id) == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
prefix_length = strlen(normalized_prefix);
|
|
*output_count = 0U;
|
|
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
sqlite3_int64 row_id = sqlite3_column_int64(statement, 0);
|
|
const char *normalized = (const char *)sqlite3_column_text(statement, 6);
|
|
bool matches = zone_id == NULL && !unclassified_only;
|
|
sqlite3_int64 data_fields = sqlite3_column_int64(statement, 5);
|
|
if (normalized == NULL || strncmp(normalized, normalized_prefix, prefix_length) != 0) {
|
|
continue;
|
|
}
|
|
if (!matches && !exercise_row_matches_body_zone(database,
|
|
row_id,
|
|
zone_id != NULL ? zone_id : "",
|
|
include_descendants,
|
|
primary_only,
|
|
unclassified_only,
|
|
&matches)) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (!matches) {
|
|
continue;
|
|
}
|
|
if (data_fields < 0 || (uint64_t)data_fields > UINT32_MAX) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (count < capacity) {
|
|
TrainlogExercise *item = &output[count];
|
|
(void)memset(item, 0, sizeof(*item));
|
|
(void)snprintf(item->exercise_id,
|
|
sizeof(item->exercise_id),
|
|
"%s",
|
|
(const char *)sqlite3_column_text(statement, 1));
|
|
(void)snprintf(item->name,
|
|
sizeof(item->name),
|
|
"%s",
|
|
(const char *)sqlite3_column_text(statement, 2));
|
|
item->tracking_mode =
|
|
tracking_mode_from_sql((const char *)sqlite3_column_text(statement, 3));
|
|
item->recording_mode =
|
|
recording_mode_from_sql((const char *)sqlite3_column_text(statement, 4));
|
|
item->data_fields = (TrainlogExerciseDataFields)data_fields;
|
|
}
|
|
++count;
|
|
}
|
|
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
*output_count = count;
|
|
return count > capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static TrainlogStatus lookup_session_row_id(TrainlogDatabase *database,
|
|
const char *session_id,
|
|
sqlite3_int64 *output_row_id) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || session_id == NULL ||
|
|
session_id[0] == '\0' || output_row_id == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"SELECT id FROM sessions WHERE session_id = ?1;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT);
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
|
|
if (rc == SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_NOT_FOUND;
|
|
}
|
|
|
|
if (rc != SQLITE_ROW) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
*output_row_id = sqlite3_column_int64(statement, 0);
|
|
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static TrainlogStatus insert_session_header(TrainlogDatabase *database,
|
|
const TrainlogSessionInput *session,
|
|
sqlite3_int64 *output_row_id) {
|
|
static const char *const SQL =
|
|
"INSERT INTO sessions("
|
|
"session_id, started_at, ended_at, session_type, session_kind, notes"
|
|
") VALUES(?1, ?2, ?3, ?4, ?5, ?6);";
|
|
|
|
sqlite3_stmt *statement = NULL;
|
|
const char *session_kind;
|
|
const char *legacy_session_type;
|
|
int rc;
|
|
|
|
session_kind = session_type_to_sql(session->session_type);
|
|
legacy_session_type =
|
|
session->session_type == TRAINLOG_SESSION_CARDIO ? "training" : session_kind;
|
|
|
|
if (session_kind == NULL || legacy_session_type == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_bind_text(statement, 1, session->session_id, -1, SQLITE_TRANSIENT);
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 2, session->started_at, -1, SQLITE_TRANSIENT);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = session->ended_at[0] != '\0'
|
|
? sqlite3_bind_text(statement, 3, session->ended_at, -1, SQLITE_TRANSIENT)
|
|
: sqlite3_bind_null(statement, 3);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 4, legacy_session_type, -1, SQLITE_STATIC);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 5, session_kind, -1, SQLITE_STATIC);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = session->notes != NULL && session->notes[0] != '\0'
|
|
? sqlite3_bind_text(statement, 6, session->notes, -1, SQLITE_TRANSIENT)
|
|
: sqlite3_bind_null(statement, 6);
|
|
}
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
|
|
if (rc == SQLITE_CONSTRAINT) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_CONFLICT;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
*output_row_id = sqlite3_last_insert_rowid(database->connection);
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static TrainlogStatus insert_session_exercise(TrainlogDatabase *database,
|
|
sqlite3_int64 session_row_id,
|
|
size_t position,
|
|
const TrainlogSessionExerciseInput *input,
|
|
sqlite3_int64 *output_row_id) {
|
|
sqlite3_stmt *statement = NULL;
|
|
sqlite3_int64 exercise_row_id;
|
|
const char *load_mode;
|
|
const char *recording_mode;
|
|
const char *tracking_mode;
|
|
char generated_entry_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
|
const char *entry_id;
|
|
int rc;
|
|
TrainlogStatus status;
|
|
|
|
if (input == NULL || output_row_id == NULL ||
|
|
(input->data_fields & ~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) != 0U) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
if (input->equipment_id[0] != '\0' &&
|
|
trainlog_equipment_catalog_lookup(input->equipment_id) == NULL &&
|
|
!custom_equipment_exists(database, input->equipment_id)) {
|
|
/* INVARIANT: a persisted occurrence names a known canonical or custom definition. */
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
/* WHY: entry_id, not position or exercise_id, is the durable identity of
|
|
* one occurrence. Local TUI creation therefore allocates it exactly once
|
|
* at persistence, while imported V2 identities pass through unchanged. */
|
|
if (input->entry_id[0] == '\0') {
|
|
status = trainlog_id_generate("sxe", generated_entry_id, sizeof(generated_entry_id));
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
entry_id = generated_entry_id;
|
|
} else {
|
|
entry_id = input->entry_id;
|
|
}
|
|
|
|
load_mode = load_mode_to_sql(input->load_mode);
|
|
|
|
recording_mode = recording_mode_to_sql(input->recording_mode);
|
|
|
|
tracking_mode = tracking_mode_to_sql(input->tracking_mode);
|
|
|
|
if (load_mode == NULL || recording_mode == NULL || tracking_mode == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
if (input->has_max_weight) {
|
|
if (!isfinite(input->max_weight_kg) || input->max_weight_kg <= 0.0 ||
|
|
input->rest_seconds != 0 || input->target_sets != 0 || input->target_reps != 0 ||
|
|
input->target_duration_seconds != 0 || input->target_has_weight ||
|
|
input->set_count != 0U || input->continuous_duration_seconds != 0 ||
|
|
input->continuous_has_speed || input->continuous_has_distance) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
} else if (input->recording_mode == TRAINLOG_RECORDING_CONTINUOUS) {
|
|
bool speed_required = (input->data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U;
|
|
|
|
bool distance_required = (input->data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U;
|
|
|
|
if (input->load_mode != TRAINLOG_LOAD_NONE || input->rest_seconds != 0 ||
|
|
input->target_sets != 0 || input->target_reps != 0 ||
|
|
input->target_duration_seconds != 0 || input->target_has_weight ||
|
|
input->set_count != 0U || input->continuous_duration_seconds <= 0 ||
|
|
input->continuous_has_speed != speed_required ||
|
|
input->continuous_has_distance != distance_required ||
|
|
(input->continuous_has_speed && input->continuous_speed_kmh <= 0.0) ||
|
|
(input->continuous_has_distance && input->continuous_distance_km <= 0.0)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
} else if (input->recording_mode == TRAINLOG_RECORDING_SETS) {
|
|
bool has_target_sets = input->target_sets > 0;
|
|
|
|
bool has_target_reps = input->target_reps > 0;
|
|
|
|
bool has_target_duration = input->target_duration_seconds > 0;
|
|
|
|
bool any_target = has_target_sets || has_target_reps || has_target_duration;
|
|
|
|
bool target_valid =
|
|
!any_target || (has_target_sets && (has_target_reps != has_target_duration));
|
|
|
|
if (input->target_sets < 0 || input->target_reps < 0 ||
|
|
input->target_duration_seconds < 0 || !target_valid ||
|
|
input->continuous_duration_seconds != 0 || input->continuous_has_speed ||
|
|
input->continuous_has_distance) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
} else {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
status = lookup_exercise_row_id(database, input->exercise_id, &exercise_row_id);
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"INSERT INTO session_exercises("
|
|
"entry_id, session_row_id, exercise_row_id, "
|
|
"recording_mode, tracking_mode, data_fields, "
|
|
"position, load_mode, rest_seconds, "
|
|
"target_sets, target_reps, "
|
|
"target_duration_seconds, "
|
|
"target_weight_kg, notes, equipment_id"
|
|
") VALUES("
|
|
"?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, "
|
|
"?9, ?10, ?11, ?12, ?13, ?14, ?15"
|
|
");",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT);
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 2, session_row_id);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 3, exercise_row_id);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 4, recording_mode, -1, SQLITE_STATIC);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 5, tracking_mode, -1, SQLITE_STATIC);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 6, (sqlite3_int64)input->data_fields);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 7, (sqlite3_int64)position);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 8, load_mode, -1, SQLITE_STATIC);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int(statement, 9, input->rest_seconds);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = input->target_sets > 0 ? sqlite3_bind_int(statement, 10, input->target_sets)
|
|
: sqlite3_bind_null(statement, 10);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = input->target_reps > 0 ? sqlite3_bind_int(statement, 11, input->target_reps)
|
|
: sqlite3_bind_null(statement, 11);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = input->target_duration_seconds > 0
|
|
? sqlite3_bind_int(statement, 12, input->target_duration_seconds)
|
|
: sqlite3_bind_null(statement, 12);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = input->target_has_weight ? sqlite3_bind_double(statement, 13, input->target_weight_kg)
|
|
: sqlite3_bind_null(statement, 13);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = input->notes != NULL && input->notes[0] != '\0'
|
|
? sqlite3_bind_text(statement, 14, input->notes, -1, SQLITE_TRANSIENT)
|
|
: sqlite3_bind_null(statement, 14);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = input->equipment_id[0] != '\0'
|
|
? sqlite3_bind_text(statement, 15, input->equipment_id, -1, SQLITE_TRANSIENT)
|
|
: sqlite3_bind_null(statement, 15);
|
|
}
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
|
|
if (rc == SQLITE_CONSTRAINT) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_CONFLICT;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
*output_row_id = sqlite3_last_insert_rowid(database->connection);
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static TrainlogStatus insert_continuous_activity(TrainlogDatabase *database,
|
|
sqlite3_int64 session_exercise_row_id,
|
|
const TrainlogSessionExerciseInput *input) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || input == NULL ||
|
|
input->recording_mode != TRAINLOG_RECORDING_CONTINUOUS ||
|
|
input->continuous_duration_seconds <= 0) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"INSERT INTO continuous_activity("
|
|
"session_exercise_row_id, "
|
|
"duration_seconds, speed_kmh, "
|
|
"distance_km"
|
|
") VALUES(?1, ?2, ?3, ?4);",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_bind_int64(statement, 1, session_exercise_row_id);
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int(statement, 2, input->continuous_duration_seconds);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = input->continuous_has_speed
|
|
? sqlite3_bind_double(statement, 3, input->continuous_speed_kmh)
|
|
: sqlite3_bind_null(statement, 3);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = input->continuous_has_distance
|
|
? sqlite3_bind_double(statement, 4, input->continuous_distance_km)
|
|
: sqlite3_bind_null(statement, 4);
|
|
}
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
|
|
if (rc == SQLITE_CONSTRAINT) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_CONFLICT;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static TrainlogStatus insert_performed_set(TrainlogDatabase *database,
|
|
sqlite3_int64 session_exercise_row_id,
|
|
size_t position,
|
|
const TrainlogSetInput *input) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || input == NULL ||
|
|
position > (size_t)INT64_MAX ||
|
|
(input->has_weight && (!isfinite(input->weight_kg) || input->weight_kg < 0.0))) {
|
|
/* CONTRACT: blank is represented by has_weight=false/SQL NULL;
|
|
* explicit zero is a finite, observed load and must survive exactly. */
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"INSERT INTO performed_sets("
|
|
"session_exercise_row_id, position, reps, duration_seconds, weight_kg"
|
|
") VALUES(?1, ?2, ?3, ?4, ?5);",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_bind_int64(statement, 1, session_exercise_row_id);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 2, (sqlite3_int64)position);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = input->duration_seconds > 0 ? sqlite3_bind_null(statement, 3)
|
|
: sqlite3_bind_int(statement, 3, input->reps);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = input->duration_seconds > 0 ? sqlite3_bind_int(statement, 4, input->duration_seconds)
|
|
: sqlite3_bind_null(statement, 4);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = input->has_weight ? sqlite3_bind_double(statement, 5, input->weight_kg)
|
|
: sqlite3_bind_null(statement, 5);
|
|
}
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
if (rc == SQLITE_CONSTRAINT) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_CONFLICT;
|
|
}
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static TrainlogStatus insert_max_result(TrainlogDatabase *database,
|
|
sqlite3_int64 session_exercise_row_id,
|
|
double max_weight_kg) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || !isfinite(max_weight_kg) ||
|
|
max_weight_kg <= 0.0) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) "
|
|
"VALUES(?1,?2);",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 1, session_exercise_row_id);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_double(statement, 2, max_weight_kg);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_step(statement);
|
|
}
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return rc == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static TrainlogStatus insert_session_children(TrainlogDatabase *database,
|
|
sqlite3_int64 session_row_id,
|
|
TrainlogSessionType session_type,
|
|
const TrainlogSessionExerciseInput *exercises,
|
|
size_t exercise_count) {
|
|
size_t exercise_index;
|
|
|
|
if (database == NULL || database->connection == NULL ||
|
|
(exercise_count > 0U && exercises == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
for (exercise_index = 0U; exercise_index < exercise_count; ++exercise_index) {
|
|
const TrainlogSessionExerciseInput *exercise = &exercises[exercise_index];
|
|
|
|
sqlite3_int64 session_exercise_row_id;
|
|
size_t set_index;
|
|
TrainlogStatus status;
|
|
|
|
if (exercise->has_max_weight && session_type != TRAINLOG_SESSION_MAX_TEST) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
status = insert_session_exercise(
|
|
database, session_row_id, exercise_index, exercise, &session_exercise_row_id);
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
|
|
if (exercise->has_max_weight) {
|
|
status = insert_max_result(database, session_exercise_row_id, exercise->max_weight_kg);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (exercise->recording_mode == TRAINLOG_RECORDING_CONTINUOUS) {
|
|
status = insert_continuous_activity(database, session_exercise_row_id, exercise);
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
for (set_index = 0U; set_index < exercise->set_count; ++set_index) {
|
|
if (exercise->sets == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
status = insert_performed_set(
|
|
database, session_exercise_row_id, set_index, &exercise->sets[set_index]);
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
}
|
|
}
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static TrainlogStatus validate_actual_set_weights(const TrainlogSessionExerciseInput *exercises,
|
|
size_t exercise_count) {
|
|
size_t exercise_index;
|
|
|
|
if ((exercise_count > 0U && exercises == NULL) || exercise_count > (size_t)INT64_MAX) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
for (exercise_index = 0U; exercise_index < exercise_count; ++exercise_index) {
|
|
const TrainlogSessionExerciseInput *exercise = &exercises[exercise_index];
|
|
size_t set_index;
|
|
|
|
if (exercise->set_count > (size_t)INT64_MAX ||
|
|
(exercise->set_count > 0U && exercise->sets == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
for (set_index = 0U; set_index < exercise->set_count; ++set_index) {
|
|
const TrainlogSetInput *set = &exercise->sets[set_index];
|
|
if (set->has_weight && (!isfinite(set->weight_kg) || set->weight_kg < 0.0)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
}
|
|
}
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_insert_session(TrainlogDatabase *database,
|
|
const TrainlogSessionInput *session) {
|
|
sqlite3_int64 session_row_id;
|
|
TrainlogStatus status;
|
|
|
|
if (database == NULL || database->connection == NULL || session == NULL ||
|
|
session->session_id[0] == '\0' || session->started_at[0] == '\0' ||
|
|
(session->exercise_count > 0U && session->exercises == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
status = validate_actual_set_weights(session->exercises, session->exercise_count);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
|
|
status = trainlog_database_begin(database);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
|
|
status = insert_session_header(database, session, &session_row_id);
|
|
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = insert_session_children(database,
|
|
session_row_id,
|
|
session->session_type,
|
|
session->exercises,
|
|
session->exercise_count);
|
|
}
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
(void)trainlog_database_rollback(database);
|
|
return status;
|
|
}
|
|
|
|
status = trainlog_database_commit(database);
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
(void)trainlog_database_rollback(database);
|
|
}
|
|
|
|
return status;
|
|
}
|
|
|
|
TrainlogStatus
|
|
trainlog_database_replace_session_exercises(TrainlogDatabase *database,
|
|
const char *session_id,
|
|
const TrainlogSessionExerciseInput *exercises,
|
|
size_t exercise_count) {
|
|
sqlite3_int64 session_row_id;
|
|
sqlite3_stmt *statement = NULL;
|
|
TrainlogSessionType session_type;
|
|
TrainlogStatus status;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || session_id == NULL ||
|
|
session_id[0] == '\0' || (exercise_count > 0U && exercises == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
status = validate_actual_set_weights(exercises, exercise_count);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
|
|
status = trainlog_database_begin(database);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
|
|
status = lookup_session_row_id(database, session_id, &session_row_id);
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
(void)trainlog_database_rollback(database);
|
|
return status;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"SELECT session_kind FROM sessions WHERE id=?1;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 1, session_row_id);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_step(statement);
|
|
}
|
|
if (rc != SQLITE_ROW ||
|
|
!session_type_from_sql((const char *)sqlite3_column_text(statement, 0), &session_type)) {
|
|
(void)sqlite3_finalize(statement);
|
|
(void)trainlog_database_rollback(database);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
rc = sqlite3_finalize(statement);
|
|
statement = NULL;
|
|
if (rc != SQLITE_OK) {
|
|
(void)trainlog_database_rollback(database);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
/* WHY: replacement reconstructs internal occurrence rows, while feedback
|
|
* belongs to the stable logical occurrence. CONTRACT: retain immutable
|
|
* feedback only when the same entry_id remains inside this same session;
|
|
* removed entries lose their parent-owned rows and new entries inherit
|
|
* nothing. INVARIANT: the temporary snapshot and reattachment are inside
|
|
* this transaction, so every failure restores the original graph. */
|
|
rc = sqlite3_exec(database->connection,
|
|
"CREATE TEMP TABLE trainlog_correction_feedback("
|
|
"feedback_id TEXT PRIMARY KEY,entry_id TEXT NOT NULL,"
|
|
"observed_at TEXT NOT NULL,raw_text TEXT NOT NULL);",
|
|
NULL,
|
|
NULL,
|
|
NULL);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"INSERT INTO trainlog_correction_feedback"
|
|
"(feedback_id,entry_id,observed_at,raw_text) "
|
|
"SELECT f.feedback_id,se.entry_id,f.observed_at,f.raw_text "
|
|
"FROM exercise_feedback AS f "
|
|
"JOIN session_exercises AS se "
|
|
"ON se.id=f.session_exercise_row_id "
|
|
"WHERE se.session_row_id=?1;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 1, session_row_id);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_step(statement);
|
|
}
|
|
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && rc == SQLITE_DONE) {
|
|
rc = SQLITE_ERROR;
|
|
}
|
|
statement = NULL;
|
|
if (rc != SQLITE_DONE) {
|
|
(void)trainlog_database_rollback(database);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
rc = sqlite3_exec(database->connection,
|
|
"CREATE TEMP TABLE trainlog_correction_feedback_revisions("
|
|
"revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL,"
|
|
"created_at TEXT NOT NULL,raw_text TEXT NOT NULL);"
|
|
"INSERT INTO trainlog_correction_feedback_revisions "
|
|
"SELECT r.revision_id,r.feedback_id,r.created_at,r.raw_text "
|
|
"FROM exercise_feedback_revisions r JOIN trainlog_correction_feedback f "
|
|
"ON f.feedback_id=r.feedback_id;",
|
|
NULL,
|
|
NULL,
|
|
NULL);
|
|
if (rc != SQLITE_OK) {
|
|
(void)trainlog_database_rollback(database);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"DELETE FROM session_exercises "
|
|
"WHERE session_row_id = ?1;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 1, session_row_id);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_step(statement);
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
(void)trainlog_database_rollback(database);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
(void)trainlog_database_rollback(database);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
statement = NULL;
|
|
|
|
status =
|
|
insert_session_children(database, session_row_id, session_type, exercises, exercise_count);
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
(void)trainlog_database_rollback(database);
|
|
return status;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"INSERT INTO exercise_feedback"
|
|
"(feedback_id,session_exercise_row_id,observed_at,raw_text) "
|
|
"SELECT saved.feedback_id,se.id,saved.observed_at,saved.raw_text "
|
|
"FROM trainlog_correction_feedback AS saved "
|
|
"JOIN session_exercises AS se ON se.entry_id=saved.entry_id "
|
|
"WHERE se.session_row_id=?1;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 1, session_row_id);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_step(statement);
|
|
}
|
|
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && rc == SQLITE_DONE) {
|
|
rc = SQLITE_ERROR;
|
|
}
|
|
statement = NULL;
|
|
if (rc == SQLITE_DONE) {
|
|
rc = sqlite3_exec(
|
|
database->connection,
|
|
"INSERT INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) "
|
|
"SELECT r.revision_id,r.feedback_id,r.created_at,r.raw_text "
|
|
"FROM trainlog_correction_feedback_revisions r "
|
|
"JOIN exercise_feedback f ON f.feedback_id=r.feedback_id;",
|
|
NULL,
|
|
NULL,
|
|
NULL);
|
|
}
|
|
if (rc != SQLITE_OK || sqlite3_exec(database->connection,
|
|
"DROP TABLE trainlog_correction_feedback_revisions;"
|
|
"DROP TABLE trainlog_correction_feedback;",
|
|
NULL,
|
|
NULL,
|
|
NULL) != SQLITE_OK) {
|
|
(void)trainlog_database_rollback(database);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
/* WHY: content can return to its earlier bytes after a real correction.
|
|
* CONTRACT: a successful replacement advances a durable non-deleted
|
|
* causal revision in the same transaction. INVARIANT: a deleted session
|
|
* cannot be modified or silently reactivated by this writer. */
|
|
{
|
|
sqlite3_stmt *causal = NULL;
|
|
rc = sqlite3_prepare_v2(
|
|
database->connection,
|
|
"INSERT INTO "
|
|
"sync_causal_state(target_kind,target_id,current_revision_id,deleted,operation_id) "
|
|
"VALUES('session',?1,'mu_'||lower(hex(randomblob(16))),0,NULL) "
|
|
"ON CONFLICT(target_kind,target_id) DO UPDATE SET "
|
|
"current_revision_id=excluded.current_revision_id "
|
|
"WHERE sync_causal_state.deleted=0;",
|
|
-1,
|
|
&causal,
|
|
NULL);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(causal, 1, session_id, -1, SQLITE_TRANSIENT);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_step(causal);
|
|
}
|
|
if (causal != NULL && sqlite3_finalize(causal) != SQLITE_OK && rc == SQLITE_DONE) {
|
|
rc = SQLITE_ERROR;
|
|
}
|
|
if (rc != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
|
(void)trainlog_database_rollback(database);
|
|
return rc == SQLITE_DONE ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
}
|
|
|
|
status = trainlog_database_commit(database);
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
(void)trainlog_database_rollback(database);
|
|
}
|
|
|
|
return status;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_list_sessions(TrainlogDatabase *database,
|
|
TrainlogSessionSummary *output,
|
|
size_t capacity,
|
|
size_t *output_count) {
|
|
/* WHY: imported and historically persisted actual work can legitimately
|
|
* have no ended_at, while a planned/empty occurrence is not observed work.
|
|
* CONTRACT: observable session history contains a session exactly once
|
|
* when it owns a performed set, continuous activity, or explicit MAX.
|
|
* INVARIANT: this read-only predicate never infers work from targets and
|
|
* never mutates or backfills the persisted lifecycle timestamps. */
|
|
static const char *const SQL =
|
|
"SELECT "
|
|
"s.session_id, "
|
|
"s.started_at, "
|
|
"COALESCE(s.ended_at, ''), "
|
|
"s.session_kind, "
|
|
"COUNT(se.id) "
|
|
"FROM sessions AS s "
|
|
"LEFT JOIN session_exercises AS se "
|
|
"ON se.session_row_id = s.id "
|
|
"WHERE EXISTS (SELECT 1 FROM session_exercises AS actual_se "
|
|
"JOIN performed_sets AS ps ON ps.session_exercise_row_id = actual_se.id "
|
|
"WHERE actual_se.session_row_id = s.id) "
|
|
"OR EXISTS (SELECT 1 FROM session_exercises AS actual_se "
|
|
"JOIN continuous_activity AS ca ON ca.session_exercise_row_id = actual_se.id "
|
|
"WHERE actual_se.session_row_id = s.id) "
|
|
"OR EXISTS (SELECT 1 FROM session_exercises AS actual_se "
|
|
"JOIN max_results AS mr ON mr.session_exercise_row_id = actual_se.id "
|
|
"WHERE actual_se.session_row_id = s.id) "
|
|
"GROUP BY s.id "
|
|
"ORDER BY s.started_at DESC, s.id DESC;";
|
|
|
|
sqlite3_stmt *statement = NULL;
|
|
size_t count = 0U;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || output_count == NULL ||
|
|
(capacity > 0U && output == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
if (count < capacity) {
|
|
const unsigned char *id = sqlite3_column_text(statement, 0);
|
|
|
|
const unsigned char *started = sqlite3_column_text(statement, 1);
|
|
|
|
const unsigned char *ended = sqlite3_column_text(statement, 2);
|
|
|
|
const unsigned char *session_type = sqlite3_column_text(statement, 3);
|
|
|
|
sqlite3_int64 exercise_count = sqlite3_column_int64(statement, 4);
|
|
|
|
if (id == NULL || started == NULL || ended == NULL || session_type == NULL ||
|
|
exercise_count < 0) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
(void)snprintf(
|
|
output[count].session_id, sizeof(output[count].session_id), "%s", (const char *)id);
|
|
|
|
(void)snprintf(output[count].started_at,
|
|
sizeof(output[count].started_at),
|
|
"%s",
|
|
(const char *)started);
|
|
|
|
(void)snprintf(
|
|
output[count].ended_at, sizeof(output[count].ended_at), "%s", (const char *)ended);
|
|
|
|
if (!session_type_from_sql((const char *)session_type, &output[count].session_type)) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
output[count].exercise_count = (size_t)exercise_count;
|
|
}
|
|
|
|
++count;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
*output_count = count < capacity ? count : capacity;
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static int bind_optional_double(sqlite3_stmt *statement, int index, bool present, double value) {
|
|
return present ? sqlite3_bind_double(statement, index, value)
|
|
: sqlite3_bind_null(statement, index);
|
|
}
|
|
|
|
TrainlogStatus
|
|
trainlog_database_insert_body_observation(TrainlogDatabase *database,
|
|
const TrainlogBodyObservationInput *observation) {
|
|
static const char *const SQL =
|
|
"INSERT INTO body_observations("
|
|
"observation_id, observed_at, session_row_id, body_weight_kg, "
|
|
"neck_cm, shoulders_cm, chest_cm, waist_cm, hips_cm, "
|
|
"left_arm_cm, right_arm_cm, left_forearm_cm, right_forearm_cm, "
|
|
"left_thigh_cm, right_thigh_cm, left_calf_cm, right_calf_cm, notes"
|
|
") VALUES("
|
|
"?1, ?2, "
|
|
"(SELECT id FROM sessions WHERE session_id = ?3), "
|
|
"?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18"
|
|
");";
|
|
sqlite3_stmt *statement = NULL;
|
|
bool any_metric;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || observation == NULL ||
|
|
observation->observation_id[0] == '\0' || observation->observed_at[0] == '\0') {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
any_metric = observation->has_body_weight || observation->has_neck ||
|
|
observation->has_shoulders || observation->has_chest || observation->has_waist ||
|
|
observation->has_hips || observation->has_left_arm || observation->has_right_arm ||
|
|
observation->has_left_forearm || observation->has_right_forearm ||
|
|
observation->has_left_thigh || observation->has_right_thigh ||
|
|
observation->has_left_calf || observation->has_right_calf;
|
|
|
|
if (!any_metric) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_bind_text(statement, 1, observation->observation_id, -1, SQLITE_TRANSIENT);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 2, observation->observed_at, -1, SQLITE_TRANSIENT);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = observation->session_id != NULL && observation->session_id[0] != '\0'
|
|
? sqlite3_bind_text(statement, 3, observation->session_id, -1, SQLITE_TRANSIENT)
|
|
: sqlite3_bind_null(statement, 3);
|
|
}
|
|
|
|
#define BIND_METRIC(index_, flag_, value_) \
|
|
do { \
|
|
if (rc == SQLITE_OK) { \
|
|
rc = bind_optional_double(statement, (index_), (flag_), (value_)); \
|
|
} \
|
|
} while (0)
|
|
|
|
BIND_METRIC(4, observation->has_body_weight, observation->body_weight_kg);
|
|
BIND_METRIC(5, observation->has_neck, observation->neck_cm);
|
|
BIND_METRIC(6, observation->has_shoulders, observation->shoulders_cm);
|
|
BIND_METRIC(7, observation->has_chest, observation->chest_cm);
|
|
BIND_METRIC(8, observation->has_waist, observation->waist_cm);
|
|
BIND_METRIC(9, observation->has_hips, observation->hips_cm);
|
|
BIND_METRIC(10, observation->has_left_arm, observation->left_arm_cm);
|
|
BIND_METRIC(11, observation->has_right_arm, observation->right_arm_cm);
|
|
BIND_METRIC(12, observation->has_left_forearm, observation->left_forearm_cm);
|
|
BIND_METRIC(13, observation->has_right_forearm, observation->right_forearm_cm);
|
|
BIND_METRIC(14, observation->has_left_thigh, observation->left_thigh_cm);
|
|
BIND_METRIC(15, observation->has_right_thigh, observation->right_thigh_cm);
|
|
BIND_METRIC(16, observation->has_left_calf, observation->left_calf_cm);
|
|
BIND_METRIC(17, observation->has_right_calf, observation->right_calf_cm);
|
|
|
|
#undef BIND_METRIC
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = observation->notes != NULL && observation->notes[0] != '\0'
|
|
? sqlite3_bind_text(statement, 18, observation->notes, -1, SQLITE_TRANSIENT)
|
|
: sqlite3_bind_null(statement, 18);
|
|
}
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
if (rc == SQLITE_CONSTRAINT) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_CONFLICT;
|
|
}
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_list_weight_points(TrainlogDatabase *database,
|
|
TrainlogWeightPoint *output,
|
|
size_t capacity,
|
|
size_t *output_count) {
|
|
sqlite3_stmt *statement = NULL;
|
|
size_t count = 0U;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || output_count == NULL ||
|
|
(capacity > 0U && output == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"SELECT observed_at, body_weight_kg "
|
|
"FROM body_observations "
|
|
"WHERE body_weight_kg IS NOT NULL "
|
|
"ORDER BY observed_at ASC, id ASC;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
if (count < capacity) {
|
|
const unsigned char *observed = sqlite3_column_text(statement, 0);
|
|
if (observed == NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
(void)snprintf(output[count].observed_at,
|
|
sizeof(output[count].observed_at),
|
|
"%s",
|
|
(const char *)observed);
|
|
output[count].body_weight_kg = sqlite3_column_double(statement, 1);
|
|
}
|
|
++count;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
*output_count = count < capacity ? count : capacity;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
/* TRAINLOG_SESSION_DETAILS_IMPLEMENTATION */
|
|
|
|
static TrainlogLoadMode detail_load_mode_from_text(const char *text) {
|
|
if (text != NULL && strcmp(text, "external") == 0) {
|
|
return TRAINLOG_LOAD_EXTERNAL;
|
|
}
|
|
|
|
if (text != NULL && strcmp(text, "assistance") == 0) {
|
|
return TRAINLOG_LOAD_ASSISTANCE;
|
|
}
|
|
|
|
return TRAINLOG_LOAD_NONE;
|
|
}
|
|
|
|
static TrainlogStatus
|
|
detail_append_text(char *output, size_t output_size, size_t *used, const char *text) {
|
|
size_t remaining;
|
|
int written;
|
|
|
|
if (output == NULL || used == NULL || text == NULL || *used >= output_size) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
remaining = output_size - *used;
|
|
|
|
written = snprintf(output + *used, remaining, "%s", text);
|
|
|
|
if (written < 0) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if ((size_t)written >= remaining) {
|
|
if (output_size >= 4U) {
|
|
output[output_size - 4U] = '.';
|
|
output[output_size - 3U] = '.';
|
|
output[output_size - 2U] = '.';
|
|
output[output_size - 1U] = '\0';
|
|
}
|
|
|
|
*used = output_size - 1U;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
*used += (size_t)written;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static TrainlogStatus detail_fill_sets(TrainlogDatabase *database,
|
|
sqlite3_int64 session_exercise_row_id,
|
|
TrainlogPersistedExerciseDetail *detail) {
|
|
static const char *const SQL = "SELECT reps, duration_seconds, weight_kg "
|
|
"FROM performed_sets "
|
|
"WHERE session_exercise_row_id = ?1 "
|
|
"ORDER BY position ASC;";
|
|
|
|
sqlite3_stmt *statement = NULL;
|
|
sqlite3_stmt *count_statement = NULL;
|
|
size_t used = 0U;
|
|
size_t count = 0U;
|
|
int rc;
|
|
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"SELECT COUNT(*) FROM performed_sets "
|
|
"WHERE session_exercise_row_id = ?1;",
|
|
-1,
|
|
&count_statement,
|
|
NULL);
|
|
if (rc != SQLITE_OK ||
|
|
sqlite3_bind_int64(count_statement, 1, session_exercise_row_id) != SQLITE_OK ||
|
|
sqlite3_step(count_statement) != SQLITE_ROW) {
|
|
(void)sqlite3_finalize(count_statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
{
|
|
sqlite3_int64 persisted_count = sqlite3_column_int64(count_statement, 0);
|
|
if (persisted_count < 0 ||
|
|
(uint64_t)persisted_count > (uint64_t)(SIZE_MAX / sizeof(*detail->actual_sets))) {
|
|
(void)sqlite3_finalize(count_statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
count = (size_t)persisted_count;
|
|
}
|
|
if (sqlite3_finalize(count_statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
count_statement = NULL;
|
|
|
|
/* WHY: performed_sets has no schema/domain maximum. Exact occurrence-owned
|
|
* storage bounds memory to the selected session without limiting history. */
|
|
if (count > 0U) {
|
|
detail->actual_sets = calloc(count, sizeof(*detail->actual_sets));
|
|
if (detail->actual_sets == NULL) {
|
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
}
|
|
}
|
|
detail->actual_set_count = count;
|
|
count = 0U;
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK) {
|
|
free(detail->actual_sets);
|
|
detail->actual_sets = NULL;
|
|
detail->actual_set_count = 0U;
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_bind_int64(statement, 1, session_exercise_row_id);
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
free(detail->actual_sets);
|
|
detail->actual_sets = NULL;
|
|
detail->actual_set_count = 0U;
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
detail->actual_summary[0] = '\0';
|
|
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
char fragment[96];
|
|
int written;
|
|
int has_reps = sqlite3_column_type(statement, 0) != SQLITE_NULL;
|
|
int has_duration = sqlite3_column_type(statement, 1) != SQLITE_NULL;
|
|
int has_weight = sqlite3_column_type(statement, 2) != SQLITE_NULL;
|
|
|
|
/* INVARIANT: COUNT and SELECT observe the same connection operation;
|
|
* any mismatch is corruption/concurrent mutation, never truncation. */
|
|
if (count >= detail->actual_set_count) {
|
|
(void)sqlite3_finalize(statement);
|
|
free(detail->actual_sets);
|
|
detail->actual_sets = NULL;
|
|
detail->actual_set_count = 0U;
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
/* INVARIANT: array index is persisted position order. Nullable load
|
|
* stays explicit instead of inheriting occurrence target metadata. */
|
|
detail->actual_sets[count].reps = has_reps != 0 ? sqlite3_column_int(statement, 0) : 0;
|
|
detail->actual_sets[count].duration_seconds =
|
|
has_duration != 0 ? sqlite3_column_int(statement, 1) : 0;
|
|
detail->actual_sets[count].has_weight = has_weight != 0;
|
|
detail->actual_sets[count].weight_kg =
|
|
has_weight != 0 ? sqlite3_column_double(statement, 2) : 0.0;
|
|
|
|
if (count > 0U) {
|
|
TrainlogStatus status = detail_append_text(
|
|
detail->actual_summary, sizeof(detail->actual_summary), &used, " / ");
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
free(detail->actual_sets);
|
|
detail->actual_sets = NULL;
|
|
detail->actual_set_count = 0U;
|
|
return status;
|
|
}
|
|
}
|
|
|
|
if (has_reps != 0) {
|
|
int reps = sqlite3_column_int(statement, 0);
|
|
|
|
if (has_weight != 0) {
|
|
written = snprintf(fragment,
|
|
sizeof(fragment),
|
|
"%d@%.1f",
|
|
reps,
|
|
sqlite3_column_double(statement, 2));
|
|
} else {
|
|
written = snprintf(fragment, sizeof(fragment), "%d", reps);
|
|
}
|
|
} else if (has_duration != 0) {
|
|
int duration = sqlite3_column_int(statement, 1);
|
|
|
|
char duration_text[64];
|
|
|
|
if (trainlog_duration_format(duration, duration_text, sizeof(duration_text)) !=
|
|
TRAINLOG_STATUS_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
free(detail->actual_sets);
|
|
detail->actual_sets = NULL;
|
|
detail->actual_set_count = 0U;
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (has_weight != 0) {
|
|
written = snprintf(fragment,
|
|
sizeof(fragment),
|
|
"%s@%.1f",
|
|
duration_text,
|
|
sqlite3_column_double(statement, 2));
|
|
} else {
|
|
written = snprintf(fragment, sizeof(fragment), "%s", duration_text);
|
|
}
|
|
} else {
|
|
(void)sqlite3_finalize(statement);
|
|
free(detail->actual_sets);
|
|
detail->actual_sets = NULL;
|
|
detail->actual_set_count = 0U;
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (written < 0 || (size_t)written >= sizeof(fragment)) {
|
|
(void)sqlite3_finalize(statement);
|
|
free(detail->actual_sets);
|
|
detail->actual_sets = NULL;
|
|
detail->actual_set_count = 0U;
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
{
|
|
TrainlogStatus status = detail_append_text(
|
|
detail->actual_summary, sizeof(detail->actual_summary), &used, fragment);
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
free(detail->actual_sets);
|
|
detail->actual_sets = NULL;
|
|
detail->actual_set_count = 0U;
|
|
return status;
|
|
}
|
|
}
|
|
|
|
++count;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
free(detail->actual_sets);
|
|
detail->actual_sets = NULL;
|
|
detail->actual_set_count = 0U;
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
free(detail->actual_sets);
|
|
detail->actual_sets = NULL;
|
|
detail->actual_set_count = 0U;
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (count != detail->actual_set_count) {
|
|
free(detail->actual_sets);
|
|
detail->actual_sets = NULL;
|
|
detail->actual_set_count = 0U;
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (count == 0U) {
|
|
(void)snprintf(
|
|
detail->actual_summary, sizeof(detail->actual_summary), "%s", "aucune série réalisée");
|
|
}
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
void trainlog_database_free_session_details(TrainlogPersistedExerciseDetail *exercises,
|
|
size_t exercise_count) {
|
|
size_t index;
|
|
|
|
if (exercises == NULL) {
|
|
return;
|
|
}
|
|
for (index = 0U; index < exercise_count; ++index) {
|
|
free(exercises[index].actual_sets);
|
|
exercises[index].actual_sets = NULL;
|
|
exercises[index].actual_set_count = 0U;
|
|
}
|
|
}
|
|
|
|
TrainlogStatus
|
|
trainlog_database_get_session_details(TrainlogDatabase *database,
|
|
const char *session_id,
|
|
TrainlogSessionSummary *output_session,
|
|
TrainlogPersistedExerciseDetail *output_exercises,
|
|
size_t exercise_capacity,
|
|
size_t *output_exercise_count) {
|
|
static const char *const HEADER_SQL = "SELECT started_at, COALESCE(ended_at, ''), session_kind "
|
|
"FROM sessions "
|
|
"WHERE session_id = ?1;";
|
|
|
|
static const char *const EXERCISE_SQL = "SELECT "
|
|
"e.name, se.tracking_mode, "
|
|
"se.recording_mode, se.data_fields, "
|
|
"se.load_mode, se.rest_seconds, "
|
|
"COALESCE(se.target_sets, 0), "
|
|
"COALESCE(se.target_reps, 0), "
|
|
"COALESCE(se.target_duration_seconds, 0), "
|
|
"se.target_weight_kg, "
|
|
"ca.duration_seconds, ca.speed_kmh, ca.distance_km, "
|
|
"mr.max_weight_kg, "
|
|
"se.equipment_id, "
|
|
"se.entry_id, "
|
|
"se.id "
|
|
"FROM session_exercises AS se "
|
|
"JOIN sessions AS s "
|
|
" ON s.id = se.session_row_id "
|
|
"JOIN exercises AS e "
|
|
" ON e.id = se.exercise_row_id "
|
|
"LEFT JOIN continuous_activity AS ca "
|
|
" ON ca.session_exercise_row_id = se.id "
|
|
"LEFT JOIN max_results AS mr "
|
|
" ON mr.session_exercise_row_id = se.id "
|
|
"WHERE s.session_id = ?1 "
|
|
"ORDER BY se.position ASC;";
|
|
|
|
sqlite3_stmt *header = NULL;
|
|
sqlite3_stmt *exercises = NULL;
|
|
size_t copied = 0U;
|
|
size_t total = 0U;
|
|
int rc;
|
|
|
|
if (output_exercise_count != NULL) {
|
|
*output_exercise_count = 0U;
|
|
}
|
|
|
|
if (database == NULL || database->connection == NULL || session_id == NULL ||
|
|
session_id[0] == '\0' || output_session == NULL || output_exercise_count == NULL ||
|
|
(exercise_capacity > 0U && output_exercises == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
(void)memset(output_session, 0, sizeof(*output_session));
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, HEADER_SQL, -1, &header, NULL);
|
|
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_bind_text(header, 1, session_id, -1, SQLITE_TRANSIENT);
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(header);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(header);
|
|
|
|
if (rc == SQLITE_DONE) {
|
|
(void)sqlite3_finalize(header);
|
|
return TRAINLOG_STATUS_NOT_FOUND;
|
|
}
|
|
|
|
if (rc != SQLITE_ROW) {
|
|
(void)sqlite3_finalize(header);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
{
|
|
const unsigned char *started = sqlite3_column_text(header, 0);
|
|
|
|
const unsigned char *ended = sqlite3_column_text(header, 1);
|
|
|
|
const unsigned char *session_type = sqlite3_column_text(header, 2);
|
|
|
|
if (started == NULL || ended == NULL || session_type == NULL) {
|
|
(void)sqlite3_finalize(header);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
(void)snprintf(
|
|
output_session->session_id, sizeof(output_session->session_id), "%s", session_id);
|
|
|
|
(void)snprintf(output_session->started_at,
|
|
sizeof(output_session->started_at),
|
|
"%s",
|
|
(const char *)started);
|
|
|
|
(void)snprintf(
|
|
output_session->ended_at, sizeof(output_session->ended_at), "%s", (const char *)ended);
|
|
|
|
if (!session_type_from_sql((const char *)session_type, &output_session->session_type)) {
|
|
(void)sqlite3_finalize(header);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
}
|
|
|
|
if (sqlite3_finalize(header) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, EXERCISE_SQL, -1, &exercises, NULL);
|
|
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_bind_text(exercises, 1, session_id, -1, SQLITE_TRANSIENT);
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(exercises);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
while ((rc = sqlite3_step(exercises)) == SQLITE_ROW) {
|
|
if (copied < exercise_capacity) {
|
|
TrainlogPersistedExerciseDetail *detail = &output_exercises[copied];
|
|
|
|
const unsigned char *name = sqlite3_column_text(exercises, 0);
|
|
|
|
const unsigned char *tracking = sqlite3_column_text(exercises, 1);
|
|
|
|
const unsigned char *recording = sqlite3_column_text(exercises, 2);
|
|
|
|
sqlite3_int64 data_fields = sqlite3_column_int64(exercises, 3);
|
|
|
|
const unsigned char *load = sqlite3_column_text(exercises, 4);
|
|
|
|
sqlite3_int64 session_exercise_row_id = sqlite3_column_int64(exercises, 16);
|
|
const unsigned char *equipment_id = sqlite3_column_text(exercises, 14);
|
|
const unsigned char *entry_id = sqlite3_column_text(exercises, 15);
|
|
|
|
TrainlogStatus status;
|
|
|
|
if (name == NULL || tracking == NULL || recording == NULL || load == NULL ||
|
|
data_fields < 0 || (uint64_t)data_fields > (uint64_t)UINT32_MAX ||
|
|
(((TrainlogExerciseDataFields)data_fields) & ~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) !=
|
|
0U) {
|
|
(void)sqlite3_finalize(exercises);
|
|
trainlog_database_free_session_details(output_exercises, copied);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
(void)memset(detail, 0, sizeof(*detail));
|
|
|
|
if (equipment_id != NULL) {
|
|
(void)snprintf(detail->equipment_id,
|
|
sizeof(detail->equipment_id),
|
|
"%s",
|
|
(const char *)equipment_id);
|
|
}
|
|
if (entry_id == NULL) {
|
|
(void)sqlite3_finalize(exercises);
|
|
trainlog_database_free_session_details(output_exercises, copied);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
(void)snprintf(
|
|
detail->entry_id, sizeof(detail->entry_id), "%s", (const char *)entry_id);
|
|
|
|
(void)snprintf(detail->name, sizeof(detail->name), "%s", (const char *)name);
|
|
|
|
detail->tracking_mode = strcmp((const char *)tracking, "duration") == 0
|
|
? TRAINLOG_TRACKING_DURATION
|
|
: TRAINLOG_TRACKING_REPS;
|
|
|
|
detail->recording_mode = recording_mode_from_sql((const char *)recording);
|
|
|
|
detail->data_fields = (TrainlogExerciseDataFields)data_fields;
|
|
|
|
detail->load_mode = detail_load_mode_from_text((const char *)load);
|
|
|
|
detail->rest_seconds = sqlite3_column_int(exercises, 5);
|
|
|
|
detail->target_sets = sqlite3_column_int(exercises, 6);
|
|
|
|
detail->target_reps = sqlite3_column_int(exercises, 7);
|
|
|
|
detail->target_duration_seconds = sqlite3_column_int(exercises, 8);
|
|
|
|
detail->has_target_weight = sqlite3_column_type(exercises, 9) != SQLITE_NULL;
|
|
|
|
detail->target_weight_kg =
|
|
detail->has_target_weight != 0 ? sqlite3_column_double(exercises, 9) : 0.0;
|
|
|
|
detail->has_max_weight = sqlite3_column_type(exercises, 13) != SQLITE_NULL;
|
|
detail->max_weight_kg =
|
|
detail->has_max_weight != 0 ? sqlite3_column_double(exercises, 13) : 0.0;
|
|
|
|
if (detail->has_max_weight != 0) {
|
|
detail->actual_set_count = 0U;
|
|
(void)snprintf(detail->actual_summary,
|
|
sizeof(detail->actual_summary),
|
|
"Max %.2f kg",
|
|
detail->max_weight_kg);
|
|
} else if (detail->recording_mode == TRAINLOG_RECORDING_CONTINUOUS) {
|
|
bool speed_required =
|
|
(detail->data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U;
|
|
|
|
bool distance_required =
|
|
(detail->data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U;
|
|
|
|
if (sqlite3_column_type(exercises, 10) == SQLITE_NULL) {
|
|
(void)sqlite3_finalize(exercises);
|
|
trainlog_database_free_session_details(output_exercises, copied);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
detail->continuous_duration_seconds = sqlite3_column_int(exercises, 10);
|
|
|
|
detail->has_continuous_speed = sqlite3_column_type(exercises, 11) != SQLITE_NULL;
|
|
|
|
detail->continuous_speed_kmh =
|
|
detail->has_continuous_speed != 0 ? sqlite3_column_double(exercises, 11) : 0.0;
|
|
|
|
detail->has_continuous_distance = sqlite3_column_type(exercises, 12) != SQLITE_NULL;
|
|
|
|
detail->continuous_distance_km = detail->has_continuous_distance != 0
|
|
? sqlite3_column_double(exercises, 12)
|
|
: 0.0;
|
|
|
|
if ((detail->has_continuous_speed != 0) != speed_required ||
|
|
(detail->has_continuous_distance != 0) != distance_required) {
|
|
(void)sqlite3_finalize(exercises);
|
|
trainlog_database_free_session_details(output_exercises, copied);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
detail->actual_set_count = 0U;
|
|
|
|
(void)snprintf(detail->actual_summary,
|
|
sizeof(detail->actual_summary),
|
|
"%s",
|
|
"activité continue");
|
|
} else {
|
|
status = detail_fill_sets(database, session_exercise_row_id, detail);
|
|
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
(void)sqlite3_finalize(exercises);
|
|
trainlog_database_free_session_details(output_exercises, copied);
|
|
return status;
|
|
}
|
|
}
|
|
|
|
++copied;
|
|
}
|
|
|
|
++total;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(exercises);
|
|
trainlog_database_free_session_details(output_exercises, copied);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(exercises) != SQLITE_OK) {
|
|
trainlog_database_free_session_details(output_exercises, copied);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
output_session->exercise_count = total;
|
|
*output_exercise_count = copied;
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
/* TRAINLOG_BODY_METRIC_HISTORY_IMPLEMENTATION */
|
|
|
|
static const char *body_metric_column(TrainlogBodyMetric metric) {
|
|
switch (metric) {
|
|
case TRAINLOG_BODY_METRIC_WEIGHT:
|
|
return "body_weight_kg";
|
|
case TRAINLOG_BODY_METRIC_NECK:
|
|
return "neck_cm";
|
|
case TRAINLOG_BODY_METRIC_SHOULDERS:
|
|
return "shoulders_cm";
|
|
case TRAINLOG_BODY_METRIC_CHEST:
|
|
return "chest_cm";
|
|
case TRAINLOG_BODY_METRIC_WAIST:
|
|
return "waist_cm";
|
|
case TRAINLOG_BODY_METRIC_HIPS:
|
|
return "hips_cm";
|
|
case TRAINLOG_BODY_METRIC_LEFT_ARM:
|
|
return "left_arm_cm";
|
|
case TRAINLOG_BODY_METRIC_RIGHT_ARM:
|
|
return "right_arm_cm";
|
|
case TRAINLOG_BODY_METRIC_LEFT_FOREARM:
|
|
return "left_forearm_cm";
|
|
case TRAINLOG_BODY_METRIC_RIGHT_FOREARM:
|
|
return "right_forearm_cm";
|
|
case TRAINLOG_BODY_METRIC_LEFT_THIGH:
|
|
return "left_thigh_cm";
|
|
case TRAINLOG_BODY_METRIC_RIGHT_THIGH:
|
|
return "right_thigh_cm";
|
|
case TRAINLOG_BODY_METRIC_LEFT_CALF:
|
|
return "left_calf_cm";
|
|
case TRAINLOG_BODY_METRIC_RIGHT_CALF:
|
|
return "right_calf_cm";
|
|
case TRAINLOG_BODY_METRIC_COUNT:
|
|
default:
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_list_body_metric_points(TrainlogDatabase *database,
|
|
TrainlogBodyMetric metric,
|
|
TrainlogBodyMetricPoint *output,
|
|
size_t capacity,
|
|
size_t *output_count) {
|
|
const char *column;
|
|
char sql[256];
|
|
sqlite3_stmt *statement = NULL;
|
|
size_t count = 0U;
|
|
int written;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || output_count == NULL ||
|
|
(capacity > 0U && output == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
column = body_metric_column(metric);
|
|
if (column == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
written = snprintf(sql,
|
|
sizeof(sql),
|
|
"SELECT observed_at, %s "
|
|
"FROM body_observations "
|
|
"WHERE %s IS NOT NULL "
|
|
"ORDER BY observed_at ASC, id ASC;",
|
|
column,
|
|
column);
|
|
|
|
if (written < 0 || (size_t)written >= sizeof(sql)) {
|
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
if (count < capacity) {
|
|
const unsigned char *observed = sqlite3_column_text(statement, 0);
|
|
|
|
if (observed == NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
(void)snprintf(output[count].observed_at,
|
|
sizeof(output[count].observed_at),
|
|
"%s",
|
|
(const char *)observed);
|
|
|
|
output[count].value = sqlite3_column_double(statement, 1);
|
|
}
|
|
|
|
++count;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
*output_count = count < capacity ? count : capacity;
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_latest_body_pair(TrainlogDatabase *database,
|
|
TrainlogBodyMetric left_metric,
|
|
TrainlogBodyMetric right_metric,
|
|
TrainlogBodyPairPoint *output) {
|
|
const char *left_column;
|
|
const char *right_column;
|
|
char sql[384];
|
|
sqlite3_stmt *statement = NULL;
|
|
int written;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || output == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
left_column = body_metric_column(left_metric);
|
|
right_column = body_metric_column(right_metric);
|
|
|
|
if (left_column == NULL || right_column == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
(void)memset(output, 0, sizeof(*output));
|
|
|
|
written = snprintf(sql,
|
|
sizeof(sql),
|
|
"SELECT observed_at, %s, %s "
|
|
"FROM body_observations "
|
|
"WHERE %s IS NOT NULL AND %s IS NOT NULL "
|
|
"ORDER BY observed_at DESC, id DESC "
|
|
"LIMIT 1;",
|
|
left_column,
|
|
right_column,
|
|
left_column,
|
|
right_column);
|
|
|
|
if (written < 0 || (size_t)written >= sizeof(sql)) {
|
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
|
|
if (rc == SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
output->found = false;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
if (rc != SQLITE_ROW) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
{
|
|
const unsigned char *observed = sqlite3_column_text(statement, 0);
|
|
|
|
if (observed == NULL) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
output->found = true;
|
|
|
|
(void)snprintf(
|
|
output->observed_at, sizeof(output->observed_at), "%s", (const char *)observed);
|
|
|
|
output->left_value = sqlite3_column_double(statement, 1);
|
|
|
|
output->right_value = sqlite3_column_double(statement, 2);
|
|
}
|
|
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
/* TRAINLOG_EXERCISE_PERFORMANCE_IMPLEMENTATION */
|
|
|
|
static TrainlogLoadMode performance_load_mode_from_sql(const char *text) {
|
|
if (text != NULL && strcmp(text, "external") == 0) {
|
|
return TRAINLOG_LOAD_EXTERNAL;
|
|
}
|
|
|
|
if (text != NULL && strcmp(text, "assistance") == 0) {
|
|
return TRAINLOG_LOAD_ASSISTANCE;
|
|
}
|
|
|
|
return TRAINLOG_LOAD_NONE;
|
|
}
|
|
|
|
static bool performance_candidate_better(const TrainlogExercisePerformancePoint *current,
|
|
TrainlogLoadMode load_mode,
|
|
int metric_value,
|
|
int has_weight,
|
|
double weight_kg) {
|
|
if (current == NULL || metric_value <= 0) {
|
|
return false;
|
|
}
|
|
|
|
if (current->has_performance == 0) {
|
|
return true;
|
|
}
|
|
|
|
switch (load_mode) {
|
|
case TRAINLOG_LOAD_EXTERNAL:
|
|
if (has_weight == 0) {
|
|
return false;
|
|
}
|
|
|
|
if (weight_kg > current->weight_kg) {
|
|
return true;
|
|
}
|
|
|
|
return weight_kg == current->weight_kg && metric_value > current->metric_value;
|
|
|
|
case TRAINLOG_LOAD_ASSISTANCE:
|
|
if (has_weight == 0) {
|
|
return false;
|
|
}
|
|
|
|
if (weight_kg < current->weight_kg) {
|
|
return true;
|
|
}
|
|
|
|
return weight_kg == current->weight_kg && metric_value > current->metric_value;
|
|
|
|
case TRAINLOG_LOAD_NONE:
|
|
default:
|
|
return metric_value > current->metric_value;
|
|
}
|
|
}
|
|
|
|
/* TRAINLOG_MEASURED_MAX_SESSION_TYPE_V1 */
|
|
TrainlogStatus trainlog_database_list_exercise_performance(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
TrainlogExercisePerformancePoint *output,
|
|
size_t capacity,
|
|
size_t *output_count) {
|
|
static const char *const SQL = "SELECT "
|
|
"s.session_id, "
|
|
"s.started_at, "
|
|
"s.session_kind, "
|
|
"se.tracking_mode, "
|
|
"se.load_mode, "
|
|
"ps.id, "
|
|
"ps.reps, "
|
|
"ps.duration_seconds, "
|
|
"ps.weight_kg, "
|
|
"mr.max_weight_kg, "
|
|
"COALESCE(se.equipment_id, '') "
|
|
"FROM session_exercises AS se "
|
|
"JOIN sessions AS s "
|
|
" ON s.id = se.session_row_id "
|
|
"JOIN exercises AS e "
|
|
" ON e.id = se.exercise_row_id "
|
|
"LEFT JOIN performed_sets AS ps "
|
|
" ON ps.session_exercise_row_id = se.id "
|
|
"LEFT JOIN max_results AS mr "
|
|
" ON mr.session_exercise_row_id = se.id "
|
|
"WHERE e.exercise_id = ?1 "
|
|
"ORDER BY "
|
|
"s.started_at DESC, "
|
|
"s.id DESC, "
|
|
"se.position ASC, ps.position ASC;";
|
|
|
|
sqlite3_stmt *statement = NULL;
|
|
TrainlogExercisePerformancePoint *current = NULL;
|
|
char current_session_id[TRAINLOG_ID_MAX + 1U];
|
|
size_t copied = 0U;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || exercise_id == NULL ||
|
|
exercise_id[0] == '\0' || output_count == NULL || (capacity > 0U && output == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
*output_count = 0U;
|
|
current_session_id[0] = '\0';
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
|
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
const unsigned char *session_id = sqlite3_column_text(statement, 0);
|
|
|
|
const unsigned char *started_at = sqlite3_column_text(statement, 1);
|
|
|
|
const unsigned char *session_type = sqlite3_column_text(statement, 2);
|
|
|
|
const unsigned char *tracking_mode = sqlite3_column_text(statement, 3);
|
|
|
|
const unsigned char *load_mode = sqlite3_column_text(statement, 4);
|
|
|
|
bool new_session;
|
|
const unsigned char *equipment_id = sqlite3_column_text(statement, 10);
|
|
|
|
if (session_id == NULL || started_at == NULL || session_type == NULL ||
|
|
tracking_mode == NULL || load_mode == NULL || equipment_id == NULL ||
|
|
(size_t)sqlite3_column_bytes(statement, 10) > TRAINLOG_ID_MAX) {
|
|
(void)sqlite3_finalize(statement);
|
|
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
new_session = current_session_id[0] == '\0' ||
|
|
strcmp(current_session_id, (const char *)session_id) != 0;
|
|
|
|
if (new_session) {
|
|
(void)snprintf(
|
|
current_session_id, sizeof(current_session_id), "%s", (const char *)session_id);
|
|
|
|
current = NULL;
|
|
|
|
if (copied < capacity) {
|
|
current = &output[copied];
|
|
|
|
(void)memset(current, 0, sizeof(*current));
|
|
|
|
(void)snprintf(current->session_id,
|
|
sizeof(current->session_id),
|
|
"%s",
|
|
(const char *)session_id);
|
|
|
|
(void)snprintf(current->started_at,
|
|
sizeof(current->started_at),
|
|
"%s",
|
|
(const char *)started_at);
|
|
|
|
if (!session_type_from_sql((const char *)session_type, ¤t->session_type)) {
|
|
(void)sqlite3_finalize(statement);
|
|
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
current->tracking_mode = tracking_mode_from_sql((const char *)tracking_mode);
|
|
|
|
current->load_mode = performance_load_mode_from_sql((const char *)load_mode);
|
|
|
|
++copied;
|
|
}
|
|
}
|
|
|
|
if (current != NULL && sqlite3_column_type(statement, 9) != SQLITE_NULL) {
|
|
double max_weight = sqlite3_column_double(statement, 9);
|
|
if (!isfinite(max_weight) || max_weight <= 0.0) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
/* Explicit max results participate in measured-max history without
|
|
* synthesizing a rep/set count. Repeated movement occurrences in
|
|
* one session reduce to the greatest recorded max. */
|
|
if (current->has_performance == 0 || max_weight > current->weight_kg) {
|
|
current->load_mode = TRAINLOG_LOAD_EXTERNAL;
|
|
current->has_performance = 1;
|
|
current->has_explicit_max = 1;
|
|
current->metric_value = 1;
|
|
current->has_weight = 1;
|
|
current->weight_kg = max_weight;
|
|
(void)snprintf(current->equipment_id,
|
|
sizeof(current->equipment_id),
|
|
"%s",
|
|
(const char *)equipment_id);
|
|
}
|
|
} else if (current != NULL && sqlite3_column_type(statement, 5) != SQLITE_NULL) {
|
|
int metric_value;
|
|
int has_weight;
|
|
double weight_kg;
|
|
|
|
++current->actual_set_count;
|
|
|
|
if (current->tracking_mode == TRAINLOG_TRACKING_REPS) {
|
|
metric_value = sqlite3_column_type(statement, 6) != SQLITE_NULL
|
|
? sqlite3_column_int(statement, 6)
|
|
: 0;
|
|
} else {
|
|
metric_value = sqlite3_column_type(statement, 7) != SQLITE_NULL
|
|
? sqlite3_column_int(statement, 7)
|
|
: 0;
|
|
}
|
|
|
|
has_weight = sqlite3_column_type(statement, 8) != SQLITE_NULL;
|
|
|
|
weight_kg = has_weight != 0 ? sqlite3_column_double(statement, 8) : 0.0;
|
|
|
|
if (performance_candidate_better(
|
|
current, current->load_mode, metric_value, has_weight, weight_kg)) {
|
|
current->has_performance = 1;
|
|
current->has_explicit_max = 0;
|
|
current->metric_value = metric_value;
|
|
current->has_weight = has_weight;
|
|
current->weight_kg = weight_kg;
|
|
(void)snprintf(current->equipment_id,
|
|
sizeof(current->equipment_id),
|
|
"%s",
|
|
(const char *)equipment_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
*output_count = copied;
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
TrainlogStatus
|
|
trainlog_database_load_session_editable(TrainlogDatabase *database,
|
|
const char *session_id,
|
|
TrainlogSessionSummary *output_session,
|
|
TrainlogEditableExerciseRecord *output_exercises,
|
|
size_t exercise_capacity,
|
|
size_t *output_exercise_count,
|
|
TrainlogSetInput *output_sets,
|
|
size_t set_capacity,
|
|
size_t *output_set_count) {
|
|
static const char *const HEADER_SQL = "SELECT "
|
|
"started_at, "
|
|
"COALESCE(ended_at, ''), "
|
|
"session_type "
|
|
"FROM sessions "
|
|
"WHERE session_id = ?1;";
|
|
|
|
static const char *const EXERCISE_SQL =
|
|
"SELECT "
|
|
"se.id, "
|
|
"e.exercise_id, "
|
|
"e.name, "
|
|
"se.tracking_mode, "
|
|
"se.load_mode, "
|
|
"se.rest_seconds, "
|
|
"se.target_sets, "
|
|
"COALESCE(se.target_reps, 0), "
|
|
"COALESCE(se.target_duration_seconds, 0), "
|
|
"se.target_weight_kg, "
|
|
"COALESCE(se.notes, ''), "
|
|
"COALESCE(se.equipment_id, ''), se.entry_id, mr.max_weight_kg, "
|
|
"se.recording_mode, se.data_fields, "
|
|
"ca.duration_seconds, ca.speed_kmh, ca.distance_km "
|
|
"FROM session_exercises AS se "
|
|
"JOIN sessions AS s "
|
|
"ON s.id = se.session_row_id "
|
|
"JOIN exercises AS e "
|
|
"ON e.id = se.exercise_row_id "
|
|
"LEFT JOIN max_results AS mr "
|
|
"ON mr.session_exercise_row_id = se.id "
|
|
"LEFT JOIN continuous_activity AS ca "
|
|
"ON ca.session_exercise_row_id = se.id "
|
|
"WHERE s.session_id = ?1 "
|
|
"ORDER BY se.position ASC;";
|
|
|
|
static const char *const SET_SQL = "SELECT reps, duration_seconds, weight_kg "
|
|
"FROM performed_sets "
|
|
"WHERE session_exercise_row_id = ?1 "
|
|
"ORDER BY position ASC;";
|
|
|
|
sqlite3_stmt *header = NULL;
|
|
sqlite3_stmt *exercise_statement = NULL;
|
|
sqlite3_stmt *set_statement = NULL;
|
|
size_t exercise_count = 0U;
|
|
size_t set_count = 0U;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || session_id == NULL ||
|
|
session_id[0] == '\0' || output_session == NULL || output_exercise_count == NULL ||
|
|
output_set_count == NULL || (exercise_capacity > 0U && output_exercises == NULL) ||
|
|
(set_capacity > 0U && output_sets == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
*output_exercise_count = 0U;
|
|
*output_set_count = 0U;
|
|
(void)memset(output_session, 0, sizeof(*output_session));
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, HEADER_SQL, -1, &header, NULL);
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(header, 1, session_id, -1, SQLITE_TRANSIENT);
|
|
}
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(header);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(header);
|
|
|
|
if (rc == SQLITE_DONE) {
|
|
(void)sqlite3_finalize(header);
|
|
return TRAINLOG_STATUS_NOT_FOUND;
|
|
}
|
|
|
|
if (rc != SQLITE_ROW) {
|
|
(void)sqlite3_finalize(header);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
{
|
|
const unsigned char *started = sqlite3_column_text(header, 0);
|
|
|
|
const unsigned char *ended = sqlite3_column_text(header, 1);
|
|
|
|
const unsigned char *session_type = sqlite3_column_text(header, 2);
|
|
|
|
if (started == NULL || ended == NULL || session_type == NULL) {
|
|
(void)sqlite3_finalize(header);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
(void)snprintf(
|
|
output_session->session_id, sizeof(output_session->session_id), "%s", session_id);
|
|
|
|
(void)snprintf(output_session->started_at,
|
|
sizeof(output_session->started_at),
|
|
"%s",
|
|
(const char *)started);
|
|
|
|
(void)snprintf(
|
|
output_session->ended_at, sizeof(output_session->ended_at), "%s", (const char *)ended);
|
|
|
|
if (!session_type_from_sql((const char *)session_type, &output_session->session_type)) {
|
|
(void)sqlite3_finalize(header);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
}
|
|
|
|
if (sqlite3_finalize(header) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
header = NULL;
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, EXERCISE_SQL, -1, &exercise_statement, NULL);
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(exercise_statement, 1, session_id, -1, SQLITE_TRANSIENT);
|
|
}
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(exercise_statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
while ((rc = sqlite3_step(exercise_statement)) == SQLITE_ROW) {
|
|
TrainlogEditableExerciseRecord *record;
|
|
sqlite3_int64 session_exercise_row_id;
|
|
const unsigned char *exercise_id;
|
|
const unsigned char *name;
|
|
const unsigned char *tracking;
|
|
const unsigned char *load;
|
|
const unsigned char *notes;
|
|
int note_bytes;
|
|
|
|
if (exercise_count >= exercise_capacity) {
|
|
(void)sqlite3_finalize(exercise_statement);
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
record = &output_exercises[exercise_count];
|
|
|
|
session_exercise_row_id = sqlite3_column_int64(exercise_statement, 0);
|
|
|
|
exercise_id = sqlite3_column_text(exercise_statement, 1);
|
|
|
|
name = sqlite3_column_text(exercise_statement, 2);
|
|
|
|
tracking = sqlite3_column_text(exercise_statement, 3);
|
|
|
|
load = sqlite3_column_text(exercise_statement, 4);
|
|
|
|
notes = sqlite3_column_text(exercise_statement, 10);
|
|
|
|
note_bytes = sqlite3_column_bytes(exercise_statement, 10);
|
|
|
|
if (exercise_id == NULL || name == NULL || tracking == NULL || load == NULL ||
|
|
notes == NULL || note_bytes < 0 || (size_t)note_bytes > TRAINLOG_NOTE_MAX) {
|
|
(void)sqlite3_finalize(exercise_statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
(void)memset(record, 0, sizeof(*record));
|
|
|
|
if (sqlite3_column_type(exercise_statement, 11) != SQLITE_NULL) {
|
|
const unsigned char *equipment_id = sqlite3_column_text(exercise_statement, 11);
|
|
if (equipment_id == NULL) {
|
|
(void)sqlite3_finalize(exercise_statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
(void)snprintf(record->equipment_id,
|
|
sizeof(record->equipment_id),
|
|
"%s",
|
|
(const char *)equipment_id);
|
|
}
|
|
|
|
if (sqlite3_column_type(exercise_statement, 12) == SQLITE_NULL) {
|
|
(void)sqlite3_finalize(exercise_statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
(void)snprintf(record->entry_id,
|
|
sizeof(record->entry_id),
|
|
"%s",
|
|
(const char *)sqlite3_column_text(exercise_statement, 12));
|
|
|
|
(void)snprintf(
|
|
record->exercise_id, sizeof(record->exercise_id), "%s", (const char *)exercise_id);
|
|
|
|
(void)snprintf(record->name, sizeof(record->name), "%s", (const char *)name);
|
|
|
|
record->tracking_mode = tracking_mode_from_sql((const char *)tracking);
|
|
|
|
record->recording_mode =
|
|
recording_mode_from_sql((const char *)sqlite3_column_text(exercise_statement, 14));
|
|
{
|
|
sqlite3_int64 data_fields = sqlite3_column_int64(exercise_statement, 15);
|
|
if (data_fields < 0 || (uint64_t)data_fields > (uint64_t)UINT32_MAX ||
|
|
(((TrainlogExerciseDataFields)data_fields) & ~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) !=
|
|
0U) {
|
|
(void)sqlite3_finalize(exercise_statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
record->data_fields = (TrainlogExerciseDataFields)data_fields;
|
|
}
|
|
|
|
record->load_mode = detail_load_mode_from_text((const char *)load);
|
|
|
|
record->rest_seconds = sqlite3_column_int(exercise_statement, 5);
|
|
|
|
record->target_sets = sqlite3_column_int(exercise_statement, 6);
|
|
|
|
record->target_reps = sqlite3_column_int(exercise_statement, 7);
|
|
|
|
record->target_duration_seconds = sqlite3_column_int(exercise_statement, 8);
|
|
|
|
record->has_target_weight = sqlite3_column_type(exercise_statement, 9) != SQLITE_NULL;
|
|
|
|
record->target_weight_kg =
|
|
record->has_target_weight != 0 ? sqlite3_column_double(exercise_statement, 9) : 0.0;
|
|
|
|
record->has_max_weight = sqlite3_column_type(exercise_statement, 13) != SQLITE_NULL;
|
|
record->max_weight_kg =
|
|
record->has_max_weight != 0 ? sqlite3_column_double(exercise_statement, 13) : 0.0;
|
|
|
|
/*
|
|
* CONTRACT: persisted-session editing must round-trip a continuous
|
|
* occurrence exactly; otherwise replacing a mixed max-test session
|
|
* could discard or invalidate its warm-up entry.
|
|
*/
|
|
if (sqlite3_column_type(exercise_statement, 16) != SQLITE_NULL) {
|
|
record->continuous_duration_seconds = sqlite3_column_int(exercise_statement, 16);
|
|
record->has_continuous_speed =
|
|
sqlite3_column_type(exercise_statement, 17) != SQLITE_NULL;
|
|
record->continuous_speed_kmh = record->has_continuous_speed != 0
|
|
? sqlite3_column_double(exercise_statement, 17)
|
|
: 0.0;
|
|
record->has_continuous_distance =
|
|
sqlite3_column_type(exercise_statement, 18) != SQLITE_NULL;
|
|
record->continuous_distance_km = record->has_continuous_distance != 0
|
|
? sqlite3_column_double(exercise_statement, 18)
|
|
: 0.0;
|
|
}
|
|
|
|
(void)snprintf(record->notes, sizeof(record->notes), "%s", (const char *)notes);
|
|
|
|
record->set_offset = set_count;
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, SET_SQL, -1, &set_statement, NULL);
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(set_statement, 1, session_exercise_row_id);
|
|
}
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(set_statement);
|
|
(void)sqlite3_finalize(exercise_statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
while ((rc = sqlite3_step(set_statement)) == SQLITE_ROW) {
|
|
TrainlogSetInput *set;
|
|
int has_reps;
|
|
int has_duration;
|
|
|
|
if (set_count >= set_capacity) {
|
|
(void)sqlite3_finalize(set_statement);
|
|
(void)sqlite3_finalize(exercise_statement);
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
set = &output_sets[set_count];
|
|
|
|
has_reps = sqlite3_column_type(set_statement, 0) != SQLITE_NULL;
|
|
|
|
has_duration = sqlite3_column_type(set_statement, 1) != SQLITE_NULL;
|
|
|
|
if (has_reps == has_duration) {
|
|
(void)sqlite3_finalize(set_statement);
|
|
(void)sqlite3_finalize(exercise_statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
(void)memset(set, 0, sizeof(*set));
|
|
|
|
if (has_reps != 0) {
|
|
set->reps = sqlite3_column_int(set_statement, 0);
|
|
} else {
|
|
set->duration_seconds = sqlite3_column_int(set_statement, 1);
|
|
}
|
|
|
|
set->has_weight = sqlite3_column_type(set_statement, 2) != SQLITE_NULL;
|
|
|
|
set->weight_kg = set->has_weight ? sqlite3_column_double(set_statement, 2) : 0.0;
|
|
|
|
++set_count;
|
|
++record->set_count;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(set_statement);
|
|
(void)sqlite3_finalize(exercise_statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(set_statement) != SQLITE_OK) {
|
|
(void)sqlite3_finalize(exercise_statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
set_statement = NULL;
|
|
++exercise_count;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(exercise_statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(exercise_statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
output_session->exercise_count = exercise_count;
|
|
|
|
*output_exercise_count = exercise_count;
|
|
|
|
*output_set_count = set_count;
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
/* TRAINLOG_BODY_OBSERVATION_RECORD_IMPLEMENTATION */
|
|
|
|
static bool body_record_fill(sqlite3_stmt *statement, TrainlogBodyObservationRecord *record) {
|
|
const unsigned char *observation_id;
|
|
const unsigned char *observed_at;
|
|
const unsigned char *session_id;
|
|
const unsigned char *notes;
|
|
int note_bytes;
|
|
|
|
if (statement == NULL || record == NULL) {
|
|
return false;
|
|
}
|
|
|
|
observation_id = sqlite3_column_text(statement, 0);
|
|
|
|
observed_at = sqlite3_column_text(statement, 1);
|
|
|
|
session_id = sqlite3_column_text(statement, 2);
|
|
|
|
notes = sqlite3_column_text(statement, 17);
|
|
|
|
note_bytes = sqlite3_column_bytes(statement, 17);
|
|
|
|
if (observation_id == NULL || observed_at == NULL || session_id == NULL || notes == NULL ||
|
|
note_bytes < 0 || (size_t)note_bytes > TRAINLOG_NOTE_MAX) {
|
|
return false;
|
|
}
|
|
|
|
(void)memset(record, 0, sizeof(*record));
|
|
|
|
(void)snprintf(
|
|
record->observation_id, sizeof(record->observation_id), "%s", (const char *)observation_id);
|
|
|
|
(void)snprintf(
|
|
record->observed_at, sizeof(record->observed_at), "%s", (const char *)observed_at);
|
|
|
|
(void)snprintf(record->session_id, sizeof(record->session_id), "%s", (const char *)session_id);
|
|
|
|
#define READ_BODY_VALUE(column_, flag_, value_) \
|
|
do { \
|
|
(flag_) = sqlite3_column_type(statement, (column_)) != SQLITE_NULL; \
|
|
(value_) = (flag_) ? sqlite3_column_double(statement, (column_)) : 0.0; \
|
|
} while (0)
|
|
|
|
READ_BODY_VALUE(3, record->has_body_weight, record->body_weight_kg);
|
|
|
|
READ_BODY_VALUE(4, record->has_neck, record->neck_cm);
|
|
|
|
READ_BODY_VALUE(5, record->has_shoulders, record->shoulders_cm);
|
|
|
|
READ_BODY_VALUE(6, record->has_chest, record->chest_cm);
|
|
|
|
READ_BODY_VALUE(7, record->has_waist, record->waist_cm);
|
|
|
|
READ_BODY_VALUE(8, record->has_hips, record->hips_cm);
|
|
|
|
READ_BODY_VALUE(9, record->has_left_arm, record->left_arm_cm);
|
|
|
|
READ_BODY_VALUE(10, record->has_right_arm, record->right_arm_cm);
|
|
|
|
READ_BODY_VALUE(11, record->has_left_forearm, record->left_forearm_cm);
|
|
|
|
READ_BODY_VALUE(12, record->has_right_forearm, record->right_forearm_cm);
|
|
|
|
READ_BODY_VALUE(13, record->has_left_thigh, record->left_thigh_cm);
|
|
|
|
READ_BODY_VALUE(14, record->has_right_thigh, record->right_thigh_cm);
|
|
|
|
READ_BODY_VALUE(15, record->has_left_calf, record->left_calf_cm);
|
|
|
|
READ_BODY_VALUE(16, record->has_right_calf, record->right_calf_cm);
|
|
|
|
#undef READ_BODY_VALUE
|
|
|
|
(void)snprintf(record->notes, sizeof(record->notes), "%s", (const char *)notes);
|
|
|
|
return true;
|
|
}
|
|
|
|
static const char *const BODY_RECORD_SELECT = "SELECT "
|
|
"bo.observation_id, "
|
|
"bo.observed_at, "
|
|
"COALESCE(s.session_id, ''), "
|
|
"bo.body_weight_kg, "
|
|
"bo.neck_cm, "
|
|
"bo.shoulders_cm, "
|
|
"bo.chest_cm, "
|
|
"bo.waist_cm, "
|
|
"bo.hips_cm, "
|
|
"bo.left_arm_cm, "
|
|
"bo.right_arm_cm, "
|
|
"bo.left_forearm_cm, "
|
|
"bo.right_forearm_cm, "
|
|
"bo.left_thigh_cm, "
|
|
"bo.right_thigh_cm, "
|
|
"bo.left_calf_cm, "
|
|
"bo.right_calf_cm, "
|
|
"COALESCE(bo.notes, '') "
|
|
"FROM body_observations AS bo "
|
|
"LEFT JOIN sessions AS s "
|
|
"ON s.id = bo.session_row_id ";
|
|
|
|
TrainlogStatus trainlog_database_list_body_observations(TrainlogDatabase *database,
|
|
TrainlogBodyObservationRecord *output,
|
|
size_t capacity,
|
|
size_t *output_count) {
|
|
char sql[1024];
|
|
sqlite3_stmt *statement = NULL;
|
|
size_t count = 0U;
|
|
int written;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || output_count == NULL ||
|
|
(capacity > 0U && output == NULL)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
written = snprintf(sql,
|
|
sizeof(sql),
|
|
"%s"
|
|
"ORDER BY bo.observed_at DESC, bo.id DESC;",
|
|
BODY_RECORD_SELECT);
|
|
|
|
if (written < 0 || (size_t)written >= sizeof(sql)) {
|
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
|
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
if (count < capacity) {
|
|
if (!body_record_fill(statement, &output[count])) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
}
|
|
|
|
++count;
|
|
}
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
*output_count = count < capacity ? count : capacity;
|
|
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_get_body_observation(TrainlogDatabase *database,
|
|
const char *observation_id,
|
|
TrainlogBodyObservationRecord *output) {
|
|
char sql[1024];
|
|
sqlite3_stmt *statement = NULL;
|
|
int written;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || observation_id == NULL ||
|
|
observation_id[0] == '\0' || output == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
written = snprintf(sql,
|
|
sizeof(sql),
|
|
"%s"
|
|
"WHERE bo.observation_id = ?1;",
|
|
BODY_RECORD_SELECT);
|
|
|
|
if (written < 0 || (size_t)written >= sizeof(sql)) {
|
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 1, observation_id, -1, SQLITE_TRANSIENT);
|
|
}
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
|
|
if (rc == SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_NOT_FOUND;
|
|
}
|
|
|
|
if (rc != SQLITE_ROW) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (!body_record_fill(statement, output)) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus
|
|
trainlog_database_update_body_observation(TrainlogDatabase *database,
|
|
const TrainlogBodyObservationInput *observation) {
|
|
static const char *const SQL = "UPDATE body_observations SET "
|
|
"body_weight_kg = ?1, "
|
|
"neck_cm = ?2, "
|
|
"shoulders_cm = ?3, "
|
|
"chest_cm = ?4, "
|
|
"waist_cm = ?5, "
|
|
"hips_cm = ?6, "
|
|
"left_arm_cm = ?7, "
|
|
"right_arm_cm = ?8, "
|
|
"left_forearm_cm = ?9, "
|
|
"right_forearm_cm = ?10, "
|
|
"left_thigh_cm = ?11, "
|
|
"right_thigh_cm = ?12, "
|
|
"left_calf_cm = ?13, "
|
|
"right_calf_cm = ?14, "
|
|
"notes = ?15 "
|
|
"WHERE observation_id = ?16;";
|
|
|
|
sqlite3_stmt *statement = NULL;
|
|
bool any_metric;
|
|
int rc;
|
|
|
|
if (database == NULL || database->connection == NULL || observation == NULL ||
|
|
observation->observation_id[0] == '\0') {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
any_metric = observation->has_body_weight || observation->has_neck ||
|
|
observation->has_shoulders || observation->has_chest || observation->has_waist ||
|
|
observation->has_hips || observation->has_left_arm || observation->has_right_arm ||
|
|
observation->has_left_forearm || observation->has_right_forearm ||
|
|
observation->has_left_thigh || observation->has_right_thigh ||
|
|
observation->has_left_calf || observation->has_right_calf;
|
|
|
|
if (!any_metric) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
|
|
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
|
|
|
|
if (rc != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
#define UPDATE_BODY_VALUE(index_, flag_, value_) \
|
|
do { \
|
|
if (rc == SQLITE_OK) { \
|
|
rc = bind_optional_double(statement, (index_), (flag_), (value_)); \
|
|
} \
|
|
} while (0)
|
|
|
|
UPDATE_BODY_VALUE(1, observation->has_body_weight, observation->body_weight_kg);
|
|
|
|
UPDATE_BODY_VALUE(2, observation->has_neck, observation->neck_cm);
|
|
|
|
UPDATE_BODY_VALUE(3, observation->has_shoulders, observation->shoulders_cm);
|
|
|
|
UPDATE_BODY_VALUE(4, observation->has_chest, observation->chest_cm);
|
|
|
|
UPDATE_BODY_VALUE(5, observation->has_waist, observation->waist_cm);
|
|
|
|
UPDATE_BODY_VALUE(6, observation->has_hips, observation->hips_cm);
|
|
|
|
UPDATE_BODY_VALUE(7, observation->has_left_arm, observation->left_arm_cm);
|
|
|
|
UPDATE_BODY_VALUE(8, observation->has_right_arm, observation->right_arm_cm);
|
|
|
|
UPDATE_BODY_VALUE(9, observation->has_left_forearm, observation->left_forearm_cm);
|
|
|
|
UPDATE_BODY_VALUE(10, observation->has_right_forearm, observation->right_forearm_cm);
|
|
|
|
UPDATE_BODY_VALUE(11, observation->has_left_thigh, observation->left_thigh_cm);
|
|
|
|
UPDATE_BODY_VALUE(12, observation->has_right_thigh, observation->right_thigh_cm);
|
|
|
|
UPDATE_BODY_VALUE(13, observation->has_left_calf, observation->left_calf_cm);
|
|
|
|
UPDATE_BODY_VALUE(14, observation->has_right_calf, observation->right_calf_cm);
|
|
|
|
#undef UPDATE_BODY_VALUE
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = observation->notes != NULL && observation->notes[0] != '\0'
|
|
? sqlite3_bind_text(statement, 15, observation->notes, -1, SQLITE_TRANSIENT)
|
|
: sqlite3_bind_null(statement, 15);
|
|
}
|
|
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 16, observation->observation_id, -1, SQLITE_TRANSIENT);
|
|
}
|
|
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
rc = sqlite3_step(statement);
|
|
|
|
if (rc != SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
|
|
return rc == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
if (sqlite3_changes(database->connection) != 1) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_NOT_FOUND;
|
|
}
|
|
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
/* TRAINING_KNOWLEDGE_RUNTIME_READ_V1 */
|
|
static bool
|
|
knowledge_copy_column(sqlite3_stmt *statement, int column, char *output, size_t capacity) {
|
|
const unsigned char *value;
|
|
size_t length;
|
|
if (statement == NULL || sqlite3_column_type(statement, column) != SQLITE_TEXT ||
|
|
output == NULL || capacity == 0U) {
|
|
return false;
|
|
}
|
|
value = sqlite3_column_text(statement, column);
|
|
if (value == NULL) {
|
|
return false;
|
|
}
|
|
length = (size_t)sqlite3_column_bytes(statement, column);
|
|
if (length >= capacity) {
|
|
return false;
|
|
}
|
|
(void)memcpy(output, value, length + 1U);
|
|
return true;
|
|
}
|
|
|
|
static bool knowledge_numeric_column(sqlite3_stmt *statement, int column) {
|
|
int type = sqlite3_column_type(statement, column);
|
|
return type == SQLITE_INTEGER || type == SQLITE_FLOAT;
|
|
}
|
|
|
|
static bool knowledge_bounded_string(const char *value, size_t capacity, size_t *length) {
|
|
const char *end;
|
|
if (value == NULL || capacity == 0U) {
|
|
return false;
|
|
}
|
|
end = memchr(value, '\0', capacity);
|
|
if (end == NULL || end == value) {
|
|
return false;
|
|
}
|
|
if (length != NULL) {
|
|
*length = (size_t)(end - value);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
typedef struct KnowledgeTemporalCandidate {
|
|
sqlite3_int64 row_id;
|
|
char *started_at;
|
|
size_t started_at_length;
|
|
char *session_id;
|
|
size_t session_id_length;
|
|
char *entry_id;
|
|
size_t entry_id_length;
|
|
TrainlogTimestampKey timestamp;
|
|
} KnowledgeTemporalCandidate;
|
|
|
|
static void knowledge_temporal_candidate_release(KnowledgeTemporalCandidate *candidate) {
|
|
if (candidate == NULL) {
|
|
return;
|
|
}
|
|
free(candidate->started_at);
|
|
free(candidate->session_id);
|
|
free(candidate->entry_id);
|
|
(void)memset(candidate, 0, sizeof(*candidate));
|
|
}
|
|
|
|
static TrainlogStatus knowledge_copy_dynamic_column(sqlite3_stmt *statement,
|
|
int column,
|
|
char **output,
|
|
size_t *output_length) {
|
|
const unsigned char *source;
|
|
size_t length;
|
|
char *copy;
|
|
if (sqlite3_column_type(statement, column) != SQLITE_TEXT) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
source = sqlite3_column_text(statement, column);
|
|
if (source == NULL) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
length = (size_t)sqlite3_column_bytes(statement, column);
|
|
if (length == 0U || memchr(source, '\0', length) != NULL || length == SIZE_MAX) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
copy = malloc(length + 1U);
|
|
if (copy == NULL) {
|
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
}
|
|
(void)memcpy(copy, source, length);
|
|
copy[length] = '\0';
|
|
*output = copy;
|
|
*output_length = length;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static int knowledge_bytes_compare(const char *left,
|
|
size_t left_length,
|
|
const char *right,
|
|
size_t right_length) {
|
|
size_t common = left_length < right_length ? left_length : right_length;
|
|
int result = memcmp(left, right, common);
|
|
if (result != 0) {
|
|
return result;
|
|
}
|
|
if (left_length == right_length) {
|
|
return 0;
|
|
}
|
|
return left_length < right_length ? -1 : 1;
|
|
}
|
|
|
|
/* INVARIANT: This is the single ordering relation used for selection and the
|
|
* exclusive cursor. Source timestamp spelling never participates in a tie. */
|
|
static int knowledge_temporal_candidate_compare(const KnowledgeTemporalCandidate *left,
|
|
const KnowledgeTemporalCandidate *right) {
|
|
int result = trainlog_timestamp_compare(&left->timestamp, &right->timestamp);
|
|
if (result != 0) {
|
|
return result;
|
|
}
|
|
result = knowledge_bytes_compare(
|
|
left->session_id, left->session_id_length, right->session_id, right->session_id_length);
|
|
if (result != 0) {
|
|
return result;
|
|
}
|
|
return knowledge_bytes_compare(
|
|
left->entry_id, left->entry_id_length, right->entry_id, right->entry_id_length);
|
|
}
|
|
|
|
static TrainlogStatus knowledge_temporal_candidate_read(sqlite3_stmt *statement,
|
|
KnowledgeTemporalCandidate *output) {
|
|
TrainlogStatus status;
|
|
(void)memset(output, 0, sizeof(*output));
|
|
if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
status = knowledge_copy_dynamic_column(
|
|
statement, 1, &output->session_id, &output->session_id_length);
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = knowledge_copy_dynamic_column(
|
|
statement, 2, &output->entry_id, &output->entry_id_length);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = knowledge_copy_dynamic_column(
|
|
statement, 3, &output->started_at, &output->started_at_length);
|
|
}
|
|
if (status != TRAINLOG_STATUS_OK || !trainlog_timestamp_parse(output->started_at,
|
|
output->started_at_length,
|
|
&output->timestamp)) {
|
|
knowledge_temporal_candidate_release(output);
|
|
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
|
|
}
|
|
output->row_id = sqlite3_column_int64(statement, 0);
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
/* Retain a descending prefix. Capacity is at most page limit + one, so a
|
|
* sorted bounded array keeps memory independent of history cardinality. */
|
|
static void knowledge_temporal_candidate_insert(KnowledgeTemporalCandidate *items,
|
|
size_t *count,
|
|
size_t capacity,
|
|
KnowledgeTemporalCandidate *candidate) {
|
|
size_t position = 0U;
|
|
size_t index;
|
|
while (position < *count &&
|
|
knowledge_temporal_candidate_compare(candidate, &items[position]) <= 0) {
|
|
++position;
|
|
}
|
|
if (position >= capacity) {
|
|
knowledge_temporal_candidate_release(candidate);
|
|
return;
|
|
}
|
|
if (*count == capacity) {
|
|
knowledge_temporal_candidate_release(&items[capacity - 1U]);
|
|
} else {
|
|
++*count;
|
|
}
|
|
for (index = *count - 1U; index > position; --index) {
|
|
items[index] = items[index - 1U];
|
|
}
|
|
items[position] = *candidate;
|
|
(void)memset(candidate, 0, sizeof(*candidate));
|
|
}
|
|
|
|
static bool knowledge_tracking_mode(const char *value, TrainlogTrackingMode *output) {
|
|
if (value == NULL || output == NULL) {
|
|
return false;
|
|
}
|
|
if (strcmp(value, "reps") == 0) {
|
|
*output = TRAINLOG_TRACKING_REPS;
|
|
return true;
|
|
}
|
|
if (strcmp(value, "duration") == 0) {
|
|
*output = TRAINLOG_TRACKING_DURATION;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool knowledge_recording_mode(const char *value, TrainlogRecordingMode *output) {
|
|
if (value == NULL || output == NULL) {
|
|
return false;
|
|
}
|
|
if (strcmp(value, "sets") == 0) {
|
|
*output = TRAINLOG_RECORDING_SETS;
|
|
return true;
|
|
}
|
|
if (strcmp(value, "continuous") == 0) {
|
|
*output = TRAINLOG_RECORDING_CONTINUOUS;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool knowledge_load_mode(const char *value, TrainlogLoadMode *output) {
|
|
if (value == NULL || output == NULL) {
|
|
return false;
|
|
}
|
|
if (strcmp(value, "none") == 0) {
|
|
*output = TRAINLOG_LOAD_NONE;
|
|
return true;
|
|
}
|
|
if (strcmp(value, "external") == 0) {
|
|
*output = TRAINLOG_LOAD_EXTERNAL;
|
|
return true;
|
|
}
|
|
if (strcmp(value, "assistance") == 0) {
|
|
*output = TRAINLOG_LOAD_ASSISTANCE;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_get_exercise_profile(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
TrainlogExercise *output) {
|
|
sqlite3_stmt *statement = NULL;
|
|
int rc;
|
|
if (database == NULL || database->connection == NULL || exercise_id == NULL ||
|
|
exercise_id[0] == '\0' || output == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
(void)memset(output, 0, sizeof(*output));
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"SELECT exercise_id,name,tracking_mode,recording_mode,data_fields FROM "
|
|
"exercises WHERE exercise_id=?1;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
|
|
}
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
rc = sqlite3_step(statement);
|
|
if (rc == SQLITE_DONE) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_NOT_FOUND;
|
|
}
|
|
if (rc != SQLITE_ROW ||
|
|
!knowledge_copy_column(statement, 0, output->exercise_id, sizeof(output->exercise_id)) ||
|
|
!knowledge_copy_column(statement, 1, output->name, sizeof(output->name)) ||
|
|
sqlite3_column_type(statement, 4) != SQLITE_INTEGER) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (sqlite3_column_type(statement, 2) != SQLITE_TEXT ||
|
|
sqlite3_column_type(statement, 3) != SQLITE_TEXT ||
|
|
!knowledge_tracking_mode((const char *)sqlite3_column_text(statement, 2),
|
|
&output->tracking_mode) ||
|
|
!knowledge_recording_mode((const char *)sqlite3_column_text(statement, 3),
|
|
&output->recording_mode)) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if ((sqlite3_column_int64(statement, 4) < 0) ||
|
|
((sqlite3_uint64)sqlite3_column_int64(statement, 4) > UINT32_MAX)) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
output->data_fields = (TrainlogExerciseDataFields)sqlite3_column_int64(statement, 4);
|
|
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
|
|
: TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus
|
|
trainlog_database_list_exercise_occurrences_page(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
const TrainlogExerciseOccurrenceCursor *after,
|
|
size_t limit,
|
|
TrainlogExerciseOccurrence *output,
|
|
size_t *output_count,
|
|
bool *output_has_more,
|
|
TrainlogExerciseOccurrenceCursor *output_next) {
|
|
static const char *const SCAN_SQL =
|
|
"SELECT se.id,s.session_id,se.entry_id,s.started_at FROM session_exercises se "
|
|
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
|
"WHERE e.exercise_id=?1;";
|
|
static const char *const HYDRATE_SQL =
|
|
"SELECT s.session_id,se.entry_id,e.exercise_id,s.started_at,COALESCE(se.equipment_id,''),"
|
|
"s.session_type,se.tracking_mode,se.recording_mode,se.data_fields,se.load_mode,"
|
|
"(SELECT COUNT(*) FROM performed_sets ps WHERE ps.session_exercise_row_id=se.id),"
|
|
"ca.duration_seconds,ca.speed_kmh,ca.distance_km FROM session_exercises se "
|
|
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
|
"LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id WHERE se.id=?1;";
|
|
sqlite3_stmt *statement = NULL;
|
|
KnowledgeTemporalCandidate selected[TRAINLOG_OCCURRENCE_PAGE_MAX + 1U] = {{0}};
|
|
KnowledgeTemporalCandidate cursor = {0};
|
|
TrainlogExerciseOccurrenceCursor after_value;
|
|
TrainlogExercise profile;
|
|
TrainlogStatus status;
|
|
size_t selected_count = 0U, count = 0U, index;
|
|
size_t cursor_started_length = 0U, cursor_session_length = 0U, cursor_entry_length = 0U;
|
|
int rc;
|
|
bool snapshot = false;
|
|
if (output_count != NULL) {
|
|
*output_count = 0U;
|
|
}
|
|
if (output_has_more != NULL) {
|
|
*output_has_more = false;
|
|
}
|
|
if (database == NULL || database->connection == NULL || exercise_id == NULL ||
|
|
exercise_id[0] == '\0' || limit == 0U || limit > TRAINLOG_OCCURRENCE_PAGE_MAX ||
|
|
output == NULL || output_count == NULL || output_has_more == NULL || output_next == NULL ||
|
|
(after != NULL &&
|
|
(!knowledge_bounded_string(
|
|
after->started_at, sizeof(after->started_at), &cursor_started_length) ||
|
|
!knowledge_bounded_string(
|
|
after->session_id, sizeof(after->session_id), &cursor_session_length) ||
|
|
!knowledge_bounded_string(
|
|
after->entry_id, sizeof(after->entry_id), &cursor_entry_length) ||
|
|
!trainlog_timestamp_parse(
|
|
after->started_at, cursor_started_length, &cursor.timestamp)))) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
if (after != NULL) {
|
|
after_value = *after;
|
|
cursor.started_at = after_value.started_at;
|
|
cursor.started_at_length = cursor_started_length;
|
|
cursor.session_id = after_value.session_id;
|
|
cursor.session_id_length = cursor_session_length;
|
|
cursor.entry_id = after_value.entry_id;
|
|
cursor.entry_id_length = cursor_entry_length;
|
|
if (!trainlog_timestamp_parse(
|
|
cursor.started_at, cursor_started_length, &cursor.timestamp)) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
}
|
|
status = trainlog_database_read_snapshot_begin(database);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
snapshot = true;
|
|
status = trainlog_database_get_exercise_profile(database, exercise_id, &profile);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto done;
|
|
}
|
|
(void)memset(output_next, 0, sizeof(*output_next));
|
|
rc = sqlite3_prepare_v2(database->connection, SCAN_SQL, -1, &statement, NULL);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
|
|
}
|
|
if (rc != SQLITE_OK) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
KnowledgeTemporalCandidate candidate;
|
|
status = knowledge_temporal_candidate_read(statement, &candidate);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto done;
|
|
}
|
|
if (after == NULL || knowledge_temporal_candidate_compare(&candidate, &cursor) < 0) {
|
|
knowledge_temporal_candidate_insert(selected, &selected_count, limit + 1U, &candidate);
|
|
}
|
|
knowledge_temporal_candidate_release(&candidate);
|
|
}
|
|
if (rc != SQLITE_DONE) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
rc = sqlite3_finalize(statement);
|
|
statement = NULL;
|
|
if (rc != SQLITE_OK) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
statement = NULL;
|
|
*output_has_more = selected_count > limit;
|
|
count = *output_has_more ? limit : selected_count;
|
|
for (index = 0U; index < count; ++index) {
|
|
TrainlogExerciseOccurrence *item;
|
|
sqlite3_int64 fields;
|
|
sqlite3_int64 sets;
|
|
rc = sqlite3_prepare_v2(database->connection, HYDRATE_SQL, -1, &statement, NULL);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 1, selected[index].row_id);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_step(statement);
|
|
}
|
|
if (rc != SQLITE_ROW) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
item = &output[index];
|
|
(void)memset(item, 0, sizeof(*item));
|
|
if (!knowledge_copy_column(statement, 0, item->session_id, sizeof(item->session_id)) ||
|
|
!knowledge_copy_column(statement, 1, item->entry_id, sizeof(item->entry_id)) ||
|
|
!knowledge_copy_column(statement, 2, item->exercise_id, sizeof(item->exercise_id)) ||
|
|
!knowledge_copy_column(statement, 3, item->started_at, sizeof(item->started_at)) ||
|
|
!knowledge_copy_column(statement, 4, item->equipment_id, sizeof(item->equipment_id)) ||
|
|
sqlite3_column_type(statement, 5) != SQLITE_TEXT ||
|
|
!session_type_from_sql((const char *)sqlite3_column_text(statement, 5),
|
|
&item->session_type)) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
if (sqlite3_column_type(statement, 6) != SQLITE_TEXT ||
|
|
sqlite3_column_type(statement, 7) != SQLITE_TEXT ||
|
|
sqlite3_column_type(statement, 9) != SQLITE_TEXT ||
|
|
!knowledge_tracking_mode((const char *)sqlite3_column_text(statement, 6),
|
|
&item->tracking_mode) ||
|
|
!knowledge_recording_mode((const char *)sqlite3_column_text(statement, 7),
|
|
&item->recording_mode) ||
|
|
!knowledge_load_mode((const char *)sqlite3_column_text(statement, 9),
|
|
&item->load_mode)) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
if (sqlite3_column_type(statement, 8) != SQLITE_INTEGER ||
|
|
sqlite3_column_type(statement, 10) != SQLITE_INTEGER) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
fields = sqlite3_column_int64(statement, 8);
|
|
sets = sqlite3_column_int64(statement, 10);
|
|
if (fields < 0 || (sqlite3_uint64)fields > UINT32_MAX || sets < 0 ||
|
|
(sqlite3_uint64)sets > SIZE_MAX) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
item->data_fields = (TrainlogExerciseDataFields)fields;
|
|
item->set_count = (size_t)sets;
|
|
if (sqlite3_column_type(statement, 11) != SQLITE_NULL) {
|
|
sqlite3_int64 duration;
|
|
if (sqlite3_column_type(statement, 11) != SQLITE_INTEGER) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
duration = sqlite3_column_int64(statement, 11);
|
|
if (duration <= 0 || duration > INT_MAX) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
item->continuous_duration_seconds = (int)duration;
|
|
}
|
|
item->continuous_has_speed = sqlite3_column_type(statement, 12) != SQLITE_NULL;
|
|
if (item->continuous_has_speed && !knowledge_numeric_column(statement, 12)) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
item->continuous_speed_kmh = sqlite3_column_double(statement, 12);
|
|
item->continuous_has_distance = sqlite3_column_type(statement, 13) != SQLITE_NULL;
|
|
if (item->continuous_has_distance && !knowledge_numeric_column(statement, 13)) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
item->continuous_distance_km = sqlite3_column_double(statement, 13);
|
|
if ((item->continuous_has_speed && !isfinite(item->continuous_speed_kmh)) ||
|
|
(item->continuous_has_distance && !isfinite(item->continuous_distance_km))) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
rc = sqlite3_step(statement);
|
|
if (rc != SQLITE_DONE) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
rc = sqlite3_finalize(statement);
|
|
statement = NULL;
|
|
if (rc != SQLITE_OK) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
}
|
|
*output_count = count;
|
|
if (count > 0U) {
|
|
(void)snprintf(output_next->started_at,
|
|
sizeof(output_next->started_at),
|
|
"%s",
|
|
output[count - 1U].started_at);
|
|
(void)snprintf(output_next->session_id,
|
|
sizeof(output_next->session_id),
|
|
"%s",
|
|
output[count - 1U].session_id);
|
|
(void)snprintf(output_next->entry_id,
|
|
sizeof(output_next->entry_id),
|
|
"%s",
|
|
output[count - 1U].entry_id);
|
|
}
|
|
status = TRAINLOG_STATUS_OK;
|
|
done:
|
|
(void)sqlite3_finalize(statement);
|
|
for (index = 0U; index < selected_count; ++index) {
|
|
knowledge_temporal_candidate_release(&selected[index]);
|
|
}
|
|
if (snapshot) {
|
|
TrainlogStatus end_status =
|
|
trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = end_status;
|
|
}
|
|
}
|
|
return status;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_list_occurrence_sets_page(TrainlogDatabase *database,
|
|
const char *entry_id,
|
|
int after_position,
|
|
size_t limit,
|
|
TrainlogOccurrenceSet *output,
|
|
size_t *output_count,
|
|
bool *output_has_more,
|
|
int *output_next_position) {
|
|
sqlite3_stmt *statement = NULL;
|
|
sqlite3_stmt *identity = NULL;
|
|
size_t count = 0U;
|
|
int rc;
|
|
if (output_count != NULL) {
|
|
*output_count = 0U;
|
|
}
|
|
if (output_has_more != NULL) {
|
|
*output_has_more = false;
|
|
}
|
|
if (database == NULL || database->connection == NULL || entry_id == NULL ||
|
|
entry_id[0] == '\0' || after_position < -1 || limit == 0U ||
|
|
limit > TRAINLOG_OCCURRENCE_SET_PAGE_MAX || output == NULL || output_count == NULL ||
|
|
output_has_more == NULL || output_next_position == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
*output_next_position = after_position;
|
|
rc = sqlite3_prepare_v2(database->connection,
|
|
"SELECT 1 FROM session_exercises WHERE entry_id=?1;",
|
|
-1,
|
|
&identity,
|
|
NULL);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(identity, 1, entry_id, -1, SQLITE_TRANSIENT);
|
|
}
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(identity);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
rc = sqlite3_step(identity);
|
|
if (rc == SQLITE_DONE) {
|
|
(void)sqlite3_finalize(identity);
|
|
return TRAINLOG_STATUS_NOT_FOUND;
|
|
}
|
|
if (rc != SQLITE_ROW || sqlite3_finalize(identity) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
rc = sqlite3_prepare_v2(
|
|
database->connection,
|
|
"SELECT ps.position,ps.reps,ps.duration_seconds,ps.weight_kg FROM performed_sets ps "
|
|
"JOIN session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id=?1 AND "
|
|
"ps.position>?2 "
|
|
"ORDER BY ps.position ASC LIMIT ?3;",
|
|
-1,
|
|
&statement,
|
|
NULL);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int(statement, 2, after_position);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 3, (sqlite3_int64)(limit + 1U));
|
|
}
|
|
if (rc != SQLITE_OK) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
TrainlogOccurrenceSet *item;
|
|
sqlite3_int64 position;
|
|
if (count == limit) {
|
|
*output_has_more = true;
|
|
break;
|
|
}
|
|
item = &output[count];
|
|
(void)memset(item, 0, sizeof(*item));
|
|
if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
position = sqlite3_column_int64(statement, 0);
|
|
if (position < 0 || position > INT_MAX) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
item->position = (size_t)position;
|
|
item->has_reps = sqlite3_column_type(statement, 1) != SQLITE_NULL;
|
|
item->has_duration = sqlite3_column_type(statement, 2) != SQLITE_NULL;
|
|
if (item->has_reps) {
|
|
sqlite3_int64 reps;
|
|
if (sqlite3_column_type(statement, 1) != SQLITE_INTEGER) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
reps = sqlite3_column_int64(statement, 1);
|
|
if (reps < 0 || reps > INT_MAX) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
item->reps = (int)reps;
|
|
}
|
|
if (item->has_duration) {
|
|
sqlite3_int64 duration;
|
|
if (sqlite3_column_type(statement, 2) != SQLITE_INTEGER) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
duration = sqlite3_column_int64(statement, 2);
|
|
if (duration <= 0 || duration > INT_MAX) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
item->duration_seconds = (int)duration;
|
|
}
|
|
item->has_weight = sqlite3_column_type(statement, 3) != SQLITE_NULL;
|
|
if (item->has_weight && !knowledge_numeric_column(statement, 3)) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
item->weight_kg = sqlite3_column_double(statement, 3);
|
|
if (item->has_weight && !isfinite(item->weight_kg)) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
*output_next_position = (int)position;
|
|
++count;
|
|
}
|
|
if (rc != SQLITE_DONE && rc != SQLITE_ROW) {
|
|
(void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
*output_count = count;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static TrainlogStatus database_latest_explicit_max_context(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
const char *equipment_id,
|
|
TrainlogLatestExplicitMax *output) {
|
|
static const char *const SCAN_SQL =
|
|
"SELECT se.id,s.session_id,se.entry_id,s.started_at FROM max_results mr "
|
|
"JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
|
|
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
|
"WHERE e.exercise_id=?1 AND s.session_type='max_test' "
|
|
"AND (?2 IS NULL OR se.equipment_id=?2);";
|
|
static const char *const HYDRATE_SQL =
|
|
"SELECT "
|
|
"s.session_id,se.entry_id,s.started_at,COALESCE(se.equipment_id,''),se.load_mode,mr.max_"
|
|
"weight_kg "
|
|
"FROM max_results mr JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
|
|
"JOIN sessions s ON s.id=se.session_row_id WHERE se.id=?1;";
|
|
sqlite3_stmt *statement = NULL;
|
|
KnowledgeTemporalCandidate selected[1] = {{0}};
|
|
size_t selected_count = 0U;
|
|
int rc;
|
|
TrainlogExercise profile;
|
|
TrainlogStatus status;
|
|
bool snapshot = false;
|
|
if (database == NULL || database->connection == NULL || exercise_id == NULL ||
|
|
exercise_id[0] == '\0' || output == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
(void)memset(output, 0, sizeof(*output));
|
|
status = trainlog_database_read_snapshot_begin(database);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
snapshot = true;
|
|
status = trainlog_database_get_exercise_profile(database, exercise_id, &profile);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto done;
|
|
}
|
|
rc = sqlite3_prepare_v2(database->connection, SCAN_SQL, -1, &statement, NULL);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = equipment_id == NULL
|
|
? sqlite3_bind_null(statement, 2)
|
|
: sqlite3_bind_text(statement, 2, equipment_id, -1, SQLITE_TRANSIENT);
|
|
}
|
|
if (rc != SQLITE_OK) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
KnowledgeTemporalCandidate candidate;
|
|
status = knowledge_temporal_candidate_read(statement, &candidate);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
goto done;
|
|
}
|
|
knowledge_temporal_candidate_insert(selected, &selected_count, 1U, &candidate);
|
|
knowledge_temporal_candidate_release(&candidate);
|
|
}
|
|
if (rc != SQLITE_DONE) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
rc = sqlite3_finalize(statement);
|
|
statement = NULL;
|
|
if (rc != SQLITE_OK) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
if (selected_count == 0U) {
|
|
status = TRAINLOG_STATUS_OK;
|
|
goto done;
|
|
}
|
|
rc = sqlite3_prepare_v2(database->connection, HYDRATE_SQL, -1, &statement, NULL);
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_bind_int64(statement, 1, selected[0].row_id);
|
|
}
|
|
if (rc == SQLITE_OK) {
|
|
rc = sqlite3_step(statement);
|
|
}
|
|
if (rc != SQLITE_ROW ||
|
|
!knowledge_copy_column(statement, 0, output->session_id, sizeof(output->session_id)) ||
|
|
!knowledge_copy_column(statement, 1, output->entry_id, sizeof(output->entry_id)) ||
|
|
!knowledge_copy_column(statement, 2, output->started_at, sizeof(output->started_at)) ||
|
|
!knowledge_copy_column(statement, 3, output->equipment_id, sizeof(output->equipment_id))) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
if (sqlite3_column_type(statement, 4) != SQLITE_TEXT ||
|
|
!knowledge_load_mode((const char *)sqlite3_column_text(statement, 4), &output->load_mode)) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
if (!knowledge_numeric_column(statement, 5)) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
output->max_weight_kg = sqlite3_column_double(statement, 5);
|
|
if (!isfinite(output->max_weight_kg) || output->max_weight_kg <= 0.0) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
output->found = true;
|
|
rc = sqlite3_step(statement);
|
|
if (rc != SQLITE_DONE) {
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
goto done;
|
|
}
|
|
rc = sqlite3_finalize(statement);
|
|
statement = NULL;
|
|
status = rc == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
done:
|
|
(void)sqlite3_finalize(statement);
|
|
knowledge_temporal_candidate_release(&selected[0]);
|
|
if (snapshot) {
|
|
TrainlogStatus end_status =
|
|
trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = end_status;
|
|
}
|
|
}
|
|
return status;
|
|
}
|
|
|
|
TrainlogStatus trainlog_database_latest_explicit_max_context(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
TrainlogLatestExplicitMax *output) {
|
|
return database_latest_explicit_max_context(database, exercise_id, NULL, output);
|
|
}
|
|
|
|
TrainlogStatus
|
|
trainlog_database_latest_explicit_max_equipment_context(TrainlogDatabase *database,
|
|
const char *exercise_id,
|
|
const char *equipment_id,
|
|
TrainlogLatestExplicitMax *output) {
|
|
if (equipment_id == NULL || equipment_id[0] == '\0') {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
return database_latest_explicit_max_context(database, exercise_id, equipment_id, output);
|
|
}
|