migration to mysql
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-07-10 09:57:44 +02:00
parent 351af170a0
commit 810ed24792
24 changed files with 986 additions and 208 deletions

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
# Database driver: mysql or sqlite (tests use sqlite)
QDB_DRIVER=mysql
# MySQL connection (required when QDB_DRIVER=mysql)
QDB_MYSQL_HOST=127.0.0.1
QDB_MYSQL_PORT=3306
QDB_MYSQL_DATABASE=nat-as-db
QDB_MYSQL_USER=nat_as_db
QDB_MYSQL_PASSWORD=change-me
# AES key for legacy SQLite file encryption and app payload crypto (32 bytes, base64)
QDB_MASTER_KEY=

View File

@ -9,6 +9,22 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.4
env:
MYSQL_DATABASE: nat_as_test
MYSQL_USER: nat_as_test
MYSQL_PASSWORD: test-password
MYSQL_ROOT_PASSWORD: root-password
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot-password"
--health-interval=5s
--health-timeout=5s
--health-retries=20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -16,7 +32,7 @@ jobs:
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: '8.2' php-version: '8.2'
extensions: json, openssl, pdo, pdo_sqlite, zip extensions: json, openssl, pdo, pdo_mysql, pdo_sqlite, zip
coverage: pcov coverage: pcov
tools: composer:v2 tools: composer:v2
@ -25,3 +41,12 @@ jobs:
- name: Run tests with coverage floor - name: Run tests with coverage floor
run: php tests/check-coverage.php 40 run: php tests/check-coverage.php 40
- name: Run MySQL schema smoke test
env:
QDB_MYSQL_HOST: 127.0.0.1
QDB_MYSQL_PORT: 3306
QDB_MYSQL_DATABASE: nat_as_test
QDB_MYSQL_USER: nat_as_test
QDB_MYSQL_PASSWORD: test-password
run: php tests/mysql_smoke.php

View File

@ -36,11 +36,37 @@ echo ".env: " . ($envPath && is_readable($envPath) ? "readable" : "MISSING or no
$keyEnv = getenv('QDB_MASTER_KEY'); $keyEnv = getenv('QDB_MASTER_KEY');
if ($keyEnv === false || $keyEnv === '') { if ($keyEnv === false || $keyEnv === '') {
echo "QDB_MASTER_KEY: NOT SET (set in .env or Apache/PHP-FPM env)\n"; echo "QDB_MASTER_KEY: NOT SET (set in .env or Apache/PHP-FPM env)\n";
exit(1); if (!qdb_is_mysql()) {
exit(1);
}
} else {
echo "QDB_MASTER_KEY: set (" . strlen($keyEnv) . " chars)\n";
$b64 = base64_decode($keyEnv, true);
echo " parses as base64: " . ($b64 !== false && strlen($b64) > 0 ? 'yes (' . strlen($b64) . ' bytes)' : 'no (uses first 32 ASCII chars)') . "\n";
} }
echo "QDB_MASTER_KEY: set (" . strlen($keyEnv) . " chars)\n";
$b64 = base64_decode($keyEnv, true); if (qdb_is_mysql()) {
echo " parses as base64: " . ($b64 !== false && strlen($b64) > 0 ? 'yes (' . strlen($b64) . ' bytes)' : 'no (uses first 32 ASCII chars)') . "\n"; echo "QDB_DRIVER: mysql\n";
echo " host: " . (qdb_env_get('QDB_MYSQL_HOST') ?: '127.0.0.1') . "\n";
echo " database: " . (qdb_env_get('QDB_MYSQL_DATABASE') ?: 'nat-as-db') . "\n";
try {
[$pdo] = qdb_mysql_open();
qdb_mysql_ensure_ready($pdo);
$version = qdb_schema_version($pdo);
$users = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
$hasSession = qdb_table_exists($pdo, 'session');
echo "\nOK: MySQL connection + migrations succeeded.\n";
echo " schema_version: $version (expected " . QDB_VERSION . ")\n";
echo " users: $users\n";
echo " session table: " . ($hasSession ? 'yes' : 'MISSING') . "\n";
exit(0);
} catch (Throwable $e) {
echo "\nMySQL open failed: " . $e->getMessage() . "\n";
exit(1);
}
}
echo "QDB_DRIVER: sqlite\n";
$dbPath = $argv[1] ?? QDB_PATH; $dbPath = $argv[1] ?? QDB_PATH;
if ($dbPath !== QDB_PATH && !is_file($dbPath)) { if ($dbPath !== QDB_PATH && !is_file($dbPath)) {

View File

@ -0,0 +1,82 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* One-time migration: encrypted SQLite file -> MySQL database.
*
* Usage:
* php cli/migrate_sqlite_to_mysql.php
*
* Requires QDB_MASTER_KEY (source SQLite) and QDB_MYSQL_* variables (target).
*/
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/response.php';
require_once __DIR__ . '/../common.php';
qdb_env_override('QDB_DRIVER', 'sqlite');
require_once __DIR__ . '/../db_init.php';
if (!is_file(QDB_PATH)) {
fwrite(STDERR, "SQLite database file not found at " . QDB_PATH . PHP_EOL);
exit(1);
}
[$sqlitePdo, $tmpDb, $lockFp] = qdb_open(false);
$tables = $sqlitePdo->query(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)->fetchAll(PDO::FETCH_COLUMN);
$tableData = [];
foreach ($tables as $table) {
$tableData[$table] = $sqlitePdo->query("SELECT * FROM `{$table}`")->fetchAll(PDO::FETCH_ASSOC);
}
qdb_discard($tmpDb, $lockFp);
qdb_reset_driver_cache();
qdb_env_override('QDB_DRIVER', 'mysql');
[$mysqlPdo] = qdb_mysql_open();
qdb_mysql_ensure_ready($mysqlPdo);
qdb_set_foreign_keys($mysqlPdo, false);
$mysqlPdo->beginTransaction();
try {
foreach ($tableData as $table => $rows) {
if (!qdb_table_exists($mysqlPdo, $table)) {
throw new RuntimeException("MySQL schema is missing source table: {$table}");
}
$mysqlPdo->exec("DELETE FROM `{$table}`");
if (!$rows) {
echo "{$table}: 0 rows\n";
continue;
}
$columns = array_keys($rows[0]);
$colList = implode(', ', array_map(fn($c) => "`{$c}`", $columns));
$placeholders = implode(', ', array_map(fn($c) => ':' . $c, $columns));
$insert = $mysqlPdo->prepare("INSERT INTO `{$table}` ({$colList}) VALUES ({$placeholders})");
foreach ($rows as $row) {
$params = [];
foreach ($columns as $column) {
$params[':' . $column] = $row[$column];
}
$insert->execute($params);
}
echo "{$table}: " . count($rows) . " rows\n";
}
$mysqlPdo->commit();
} catch (Throwable $e) {
if ($mysqlPdo->inTransaction()) {
$mysqlPdo->rollBack();
}
throw $e;
} finally {
qdb_set_foreign_keys($mysqlPdo, true);
}
qdb_set_schema_version($mysqlPdo, QDB_VERSION);
echo "Migration complete. Set QDB_DRIVER=mysql in .env and restart PHP.\n";

View File

@ -76,6 +76,9 @@ function qdb_parse_env_file(string $path): array {
* Read an environment variable (.env file first, then getenv / $_ENV / $_SERVER). * Read an environment variable (.env file first, then getenv / $_ENV / $_SERVER).
*/ */
function qdb_env_get(string $name): ?string { function qdb_env_get(string $name): ?string {
if (isset($GLOBALS['qdb_env_overrides'][$name])) {
return (string)$GLOBALS['qdb_env_overrides'][$name];
}
$fileVars = qdb_parse_env_file(qdb_env_file_path()); $fileVars = qdb_parse_env_file(qdb_env_file_path());
if (isset($fileVars[$name]) && $fileVars[$name] !== '') { if (isset($fileVars[$name]) && $fileVars[$name] !== '') {
return $fileVars[$name]; return $fileVars[$name];
@ -110,6 +113,15 @@ function qdb_env_set(string $name, string $value): void {
} }
} }
/** Override a configuration value for the current process, ahead of .env. */
function qdb_env_override(string $name, string $value): void {
$GLOBALS['qdb_env_overrides'][$name] = $value;
qdb_env_set($name, $value);
if ($name === 'QDB_DRIVER' && function_exists('qdb_reset_driver_cache')) {
qdb_reset_driver_cache();
}
}
/** Load KEY=value pairs from .env into $GLOBALS['qdb_dotenv'] and $_ENV. */ /** Load KEY=value pairs from .env into $GLOBALS['qdb_dotenv'] and $_ENV. */
function qdb_load_dotenv(string $path): void { function qdb_load_dotenv(string $path): void {
foreach (qdb_parse_env_file($path) as $name => $value) { foreach (qdb_parse_env_file($path) as $name => $value) {
@ -382,19 +394,18 @@ define('QDB_SOURCE_LANGUAGE', 'de');
function qdb_ensure_source_language(PDO $pdo): void { function qdb_ensure_source_language(PDO $pdo): void {
if (qdb_column_exists($pdo, 'language', 'enabled')) { if (qdb_column_exists($pdo, 'language', 'enabled')) {
$pdo->prepare("INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1) $pdo->prepare('INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1) '
ON CONFLICT(languageCode) DO UPDATE SET enabled = 1") . qdb_upsert_update(['languageCode'], ['enabled' => '1', 'name' => 'excluded.name']))
->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']); ->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']);
return; return;
} }
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n) $pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n) '
ON CONFLICT(languageCode) DO NOTHING") . qdb_upsert_do_nothing(['languageCode']))
->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']); ->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']);
} }
/** @return list<array{languageCode:string,name:string,enabled:int}> */ /** @return list<array{languageCode:string,name:string,enabled:int}> */
function qdb_load_languages(PDO $pdo): array { function qdb_load_languages(PDO $pdo): array {
qdb_ensure_source_language($pdo);
$hasEnabled = qdb_column_exists($pdo, 'language', 'enabled'); $hasEnabled = qdb_column_exists($pdo, 'language', 'enabled');
if ($hasEnabled) { if ($hasEnabled) {
$rows = $pdo->query( $rows = $pdo->query(
@ -439,9 +450,9 @@ function qdb_set_language_enabled(PDO $pdo, string $languageCode, bool $enabled)
if (!qdb_column_exists($pdo, 'language', 'enabled')) { if (!qdb_column_exists($pdo, 'language', 'enabled')) {
throw new RuntimeException('Language enablement is not available on this database'); throw new RuntimeException('Language enablement is not available on this database');
} }
$pdo->prepare('UPDATE language SET enabled = :en WHERE languageCode = :lc') $stmt = $pdo->prepare('UPDATE language SET enabled = :en WHERE languageCode = :lc');
->execute([':en' => $enabled ? 1 : 0, ':lc' => $languageCode]); $stmt->execute([':en' => $enabled ? 1 : 0, ':lc' => $languageCode]);
if ($pdo->query('SELECT changes()')->fetchColumn() == 0) { if (qdb_stmt_affected_rows($pdo, $stmt) === 0) {
throw new InvalidArgumentException('Language not found'); throw new InvalidArgumentException('Language not found');
} }
} }
@ -496,7 +507,7 @@ function qdb_set_questionnaire_language_disabled(
if ($disabled) { if ($disabled) {
$pdo->prepare( $pdo->prepare(
'INSERT INTO questionnaire_language_disable (questionnaireID, languageCode) 'INSERT INTO questionnaire_language_disable (questionnaireID, languageCode)
VALUES (:qid, :lc) ON CONFLICT(questionnaireID, languageCode) DO NOTHING' VALUES (:qid, :lc) ' . qdb_upsert_do_nothing(['questionnaireID', 'languageCode'])
)->execute([':qid' => $questionnaireID, ':lc' => $languageCode]); )->execute([':qid' => $questionnaireID, ':lc' => $languageCode]);
return; return;
} }
@ -1161,24 +1172,24 @@ function qdb_app_ui_string_entries_for_website(PDO $pdo): array {
/** Upsert one translation row; sync German text into defaultText for questions/options. */ /** Upsert one translation row; sync German text into defaultText for questions/options. */
function qdb_put_translation(PDO $pdo, string $type, string $id, string $lang, string $text): void { function qdb_put_translation(PDO $pdo, string $type, string $id, string $lang, string $text): void {
if ($type === 'question') { if ($type === 'question') {
$pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text) $pdo->prepare('INSERT INTO question_translation (questionID, languageCode, text)
VALUES (:id, :lang, :t) VALUES (:id, :lang, :t) '
ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text") . qdb_upsert_update(['questionID', 'languageCode'], ['text' => 'excluded.text']))
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
if ($lang === QDB_SOURCE_LANGUAGE) { if ($lang === QDB_SOURCE_LANGUAGE) {
$pdo->prepare("UPDATE question SET defaultText = :t WHERE questionID = :id") $pdo->prepare("UPDATE question SET defaultText = :t WHERE questionID = :id")
->execute([':t' => $text, ':id' => $id]); ->execute([':t' => $text, ':id' => $id]);
} }
} elseif ($type === 'answer_option') { } elseif ($type === 'answer_option') {
$pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text) $pdo->prepare('INSERT INTO answer_option_translation (answerOptionID, languageCode, text)
VALUES (:id, :lang, :t) VALUES (:id, :lang, :t) '
ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text") . qdb_upsert_update(['answerOptionID', 'languageCode'], ['text' => 'excluded.text']))
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
// defaultText holds optionKey; German label lives only in answer_option_translation. // defaultText holds optionKey; German label lives only in answer_option_translation.
} elseif ($type === 'string' || $type === 'app_string') { } elseif ($type === 'string' || $type === 'app_string') {
$pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text) $pdo->prepare('INSERT INTO string_translation (stringKey, languageCode, text)
VALUES (:id, :lang, :t) VALUES (:id, :lang, :t) '
ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text") . qdb_upsert_update(['stringKey', 'languageCode'], ['text' => 'excluded.text']))
->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]);
} }
} }
@ -2500,8 +2511,8 @@ function qdb_import_translations_bundle(PDO $pdo, array $bundle): array {
if ($lc === '' || $lc === QDB_SOURCE_LANGUAGE) { if ($lc === '' || $lc === QDB_SOURCE_LANGUAGE) {
continue; continue;
} }
$pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n) $pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n) '
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name') . qdb_upsert_update(['languageCode'], ['name' => 'excluded.name']))
->execute([':lc' => $lc, ':n' => $name]); ->execute([':lc' => $lc, ':n' => $name]);
} }

View File

@ -1,12 +1,13 @@
{ {
"name": "nat-as/questionnaire-server", "name": "nat-as/questionnaire-server",
"description": "Encrypted SQLite questionnaire API", "description": "Questionnaire API (MySQL or legacy encrypted SQLite)",
"type": "project", "type": "project",
"require": { "require": {
"php": ">=8.2", "php": ">=8.2",
"ext-json": "*", "ext-json": "*",
"ext-openssl": "*", "ext-openssl": "*",
"ext-pdo": "*", "ext-pdo": "*",
"ext-pdo_mysql": "*",
"ext-pdo_sqlite": "*" "ext-pdo_sqlite": "*"
}, },
"require-dev": { "require-dev": {

View File

@ -1,7 +1,7 @@
<?php <?php
// /var/www/html/db_init.php // Shared helpers for opening, creating, and saving the questionnaire database.
// Shared helpers for opening, creating, and saving the encrypted SQLite database.
require_once __DIR__ . '/common.php'; require_once __DIR__ . '/common.php';
require_once __DIR__ . '/lib/sql_dialect.php';
if (defined('QDB_TEST_UPLOADS')) { if (defined('QDB_TEST_UPLOADS')) {
define('QDB_UPLOADS_DIR', QDB_TEST_UPLOADS); define('QDB_UPLOADS_DIR', QDB_TEST_UPLOADS);
@ -15,26 +15,8 @@ if (defined('QDB_TEST_UPLOADS')) {
define('QDB_SCHEMA', __DIR__ . '/schema.sql'); define('QDB_SCHEMA', __DIR__ . '/schema.sql');
define('QDB_VERSION', 14); define('QDB_VERSION', 14);
function qdb_table_exists(PDO $pdo, string $table): bool {
$stmt = $pdo->prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :t LIMIT 1"
);
$stmt->execute([':t' => $table]);
return (bool)$stmt->fetchColumn();
}
function qdb_column_exists(PDO $pdo, string $table, string $column): bool {
$stmt = $pdo->query('PRAGMA table_info(' . $table . ')');
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) {
if (($col['name'] ?? '') === $column) {
return true;
}
}
return false;
}
/** /**
* Apply incremental schema fixes. Returns true if the temp DB was modified. * Apply incremental schema fixes. Returns true if the DB was modified.
*/ */
function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
$changed = false; $changed = false;
@ -138,8 +120,8 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
UNIQUE(clientCode, questionnaireID, version) UNIQUE(clientCode, questionnaireID, version)
) )
"); ");
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_submission_client_qn ON questionnaire_submission(clientCode, questionnaireID)'); qdb_create_index_if_not_exists($pdo, 'idx_submission_client_qn', 'questionnaire_submission', 'clientCode, questionnaireID');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_submission_client_time ON questionnaire_submission(clientCode, submittedAt)'); qdb_create_index_if_not_exists($pdo, 'idx_submission_client_time', 'questionnaire_submission', 'clientCode, submittedAt');
$changed = true; $changed = true;
} }
@ -211,7 +193,7 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
UNIQUE(questionID, scopeKey, levelKey) UNIQUE(questionID, scopeKey, levelKey)
) )
"); ");
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_score_rule_question ON question_score_rule(questionID)'); qdb_create_index_if_not_exists($pdo, 'idx_score_rule_question', 'question_score_rule', 'questionID');
$changed = true; $changed = true;
} }
@ -267,7 +249,7 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE
) )
"); ");
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_client_profile_result_profile ON client_scoring_profile_result(profileID)'); qdb_create_index_if_not_exists($pdo, 'idx_client_profile_result_profile', 'client_scoring_profile_result', 'profileID');
$changed = true; $changed = true;
} }
@ -293,7 +275,7 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
} }
} }
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); $currentVersion = qdb_schema_version($pdo);
if ($currentVersion < 10 if ($currentVersion < 10
&& qdb_table_exists($pdo, 'completed_questionnaire') && qdb_table_exists($pdo, 'completed_questionnaire')
&& qdb_table_exists($pdo, 'client_scoring_profile_result')) { && qdb_table_exists($pdo, 'client_scoring_profile_result')) {
@ -303,7 +285,7 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
} }
} }
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); $currentVersion = qdb_schema_version($pdo);
if ($currentVersion < 7 && qdb_table_exists($pdo, 'question_score_rule')) { if ($currentVersion < 7 && qdb_table_exists($pdo, 'question_score_rule')) {
require_once __DIR__ . '/lib/scoring.php'; require_once __DIR__ . '/lib/scoring.php';
if (qdb_backfill_glass_score_rules($pdo) > 0) { if (qdb_backfill_glass_score_rules($pdo) > 0) {
@ -317,7 +299,7 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
} }
} }
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); $currentVersion = qdb_schema_version($pdo);
if ($currentVersion < 8 && qdb_table_exists($pdo, 'scoring_profile')) { if ($currentVersion < 8 && qdb_table_exists($pdo, 'scoring_profile')) {
if (!qdb_column_exists($pdo, 'scoring_profile', 'greenMin')) { if (!qdb_column_exists($pdo, 'scoring_profile', 'greenMin')) {
$pdo->exec('ALTER TABLE scoring_profile ADD COLUMN greenMin INTEGER NOT NULL DEFAULT 0'); $pdo->exec('ALTER TABLE scoring_profile ADD COLUMN greenMin INTEGER NOT NULL DEFAULT 0');
@ -340,7 +322,7 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
$changed = true; $changed = true;
} }
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); $currentVersion = qdb_schema_version($pdo);
if ($currentVersion < 13 && qdb_table_exists($pdo, 'scoring_profile')) { if ($currentVersion < 13 && qdb_table_exists($pdo, 'scoring_profile')) {
if (!qdb_column_exists($pdo, 'scoring_profile', 'processCompleteBands')) { if (!qdb_column_exists($pdo, 'scoring_profile', 'processCompleteBands')) {
$pdo->exec("ALTER TABLE scoring_profile ADD COLUMN processCompleteBands TEXT NOT NULL DEFAULT '[]'"); $pdo->exec("ALTER TABLE scoring_profile ADD COLUMN processCompleteBands TEXT NOT NULL DEFAULT '[]'");
@ -348,7 +330,7 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
} }
} }
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); $currentVersion = qdb_schema_version($pdo);
if ($currentVersion < 14) { if ($currentVersion < 14) {
if (qdb_table_exists($pdo, 'language') && !qdb_column_exists($pdo, 'language', 'enabled')) { if (qdb_table_exists($pdo, 'language') && !qdb_column_exists($pdo, 'language', 'enabled')) {
$pdo->exec('ALTER TABLE language ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1'); $pdo->exec('ALTER TABLE language ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1');
@ -368,7 +350,7 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
} }
} }
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); $currentVersion = qdb_schema_version($pdo);
if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) { if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) {
$pdo->exec( $pdo->exec(
'UPDATE questionnaire_submission SET submittedAt = completedAt 'UPDATE questionnaire_submission SET submittedAt = completedAt
@ -377,27 +359,55 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
$changed = true; $changed = true;
} }
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); $currentVersion = qdb_schema_version($pdo);
if ($currentVersion < QDB_VERSION) { if ($currentVersion < QDB_VERSION) {
$pdo->exec('PRAGMA foreign_keys = OFF;'); qdb_set_foreign_keys($pdo, false);
$pdo->exec($schemaSql); try {
if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) { qdb_exec_schema_sql($pdo, $schemaSql);
$pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''"); if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) {
$pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''");
}
qdb_set_schema_version($pdo, QDB_VERSION);
} finally {
qdb_set_foreign_keys($pdo, true);
} }
$pdo->exec('PRAGMA user_version = ' . QDB_VERSION . ';');
$pdo->exec('PRAGMA foreign_keys = ON;');
$changed = true; $changed = true;
} }
return $changed; return $changed;
} }
/** function qdb_mysql_ensure_ready(PDO $pdo): void {
* Exclusive lock with short retries (non-blocking attempts). Avoids hung PHP workers static $ready = false;
* when two writes overlap (app upload + website edit). if ($ready) {
*/ return;
function qdb_flock_exclusive_with_retry($lockFp, int $maxAttempts = 12, int $sleepMicros = 75000): void }
{
$lockName = 'nat_as_schema_migration';
$lockStmt = $pdo->prepare('SELECT GET_LOCK(:name, 30)');
$lockStmt->execute([':name' => $lockName]);
if ((int)$lockStmt->fetchColumn() !== 1) {
throw new RuntimeException('Could not acquire MySQL schema migration lock');
}
try {
$schemaPath = qdb_schema_file();
$schemaSql = file_get_contents($schemaPath);
if ($schemaSql === false) {
throw new Exception('Could not read MySQL schema');
}
if (!qdb_table_exists($pdo, 'users') || qdb_schema_version($pdo) < QDB_VERSION) {
qdb_exec_schema_file($pdo, $schemaPath);
}
qdb_apply_migrations($pdo, $schemaSql);
$ready = true;
} finally {
$releaseStmt = $pdo->prepare('SELECT RELEASE_LOCK(:name)');
$releaseStmt->execute([':name' => $lockName]);
}
}
function qdb_flock_exclusive_with_retry($lockFp, int $maxAttempts = 12, int $sleepMicros = 75000): void {
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
if (flock($lockFp, LOCK_EX | LOCK_NB)) { if (flock($lockFp, LOCK_EX | LOCK_NB)) {
return; return;
@ -409,8 +419,7 @@ function qdb_flock_exclusive_with_retry($lockFp, int $maxAttempts = 12, int $sle
throw new Exception('Could not acquire lock'); throw new Exception('Could not acquire lock');
} }
function qdb_open_writable_lock() function qdb_open_writable_lock() {
{
$lockFp = fopen(QDB_LOCK, 'c'); $lockFp = fopen(QDB_LOCK, 'c');
if ($lockFp === false) { if ($lockFp === false) {
throw new Exception('Could not open lock file'); throw new Exception('Could not open lock file');
@ -424,14 +433,15 @@ function qdb_acquire_lock() {
} }
/** /**
* Decrypt the master DB file into a temp SQLite file, or create a fresh one
* from schema.sql if no DB exists yet.
*
* Returns [PDO $pdo, string $tmpPath, resource|null $lockFp]. * Returns [PDO $pdo, string $tmpPath, resource|null $lockFp].
* If $writable is true, an exclusive lock is held -- caller MUST call
* qdb_save() or qdb_discard() afterwards.
*/ */
function qdb_open(bool $writable = false): array { function qdb_open(bool $writable = false): array {
if (qdb_is_mysql()) {
[$pdo] = qdb_mysql_open();
qdb_mysql_ensure_ready($pdo);
return [$pdo, '', null];
}
if (!$writable) { if (!$writable) {
require_once __DIR__ . '/lib/read_db_cache.php'; require_once __DIR__ . '/lib/read_db_cache.php';
return qdb_read_db_open(); return qdb_read_db_open();
@ -441,29 +451,28 @@ function qdb_open(bool $writable = false): array {
if (!is_dir(dirname($dbPath))) { if (!is_dir(dirname($dbPath))) {
if (!mkdir(dirname($dbPath), 0755, true) && !is_dir(dirname($dbPath))) { if (!mkdir(dirname($dbPath), 0755, true) && !is_dir(dirname($dbPath))) {
throw new Exception("Could not create uploads directory"); throw new Exception('Could not create uploads directory');
} }
} }
$lockFp = null; $lockFp = qdb_open_writable_lock();
if ($writable) {
$lockFp = qdb_open_writable_lock();
}
$tmpDb = tempnam(sys_get_temp_dir(), 'qdb_'); $tmpDb = tempnam(sys_get_temp_dir(), 'qdb_');
$masterKey = get_master_key_bytes(); $masterKey = get_master_key_bytes();
$sql = file_get_contents(QDB_SCHEMA); $sql = file_get_contents(QDB_SCHEMA);
if ($sql === false) { if ($sql === false) {
if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); } flock($lockFp, LOCK_UN);
throw new Exception("Could not read schema.sql"); fclose($lockFp);
throw new Exception('Could not read schema.sql');
} }
if (file_exists($dbPath) && is_file($dbPath)) { if (file_exists($dbPath) && is_file($dbPath)) {
$storedEnc = @file_get_contents($dbPath); $storedEnc = @file_get_contents($dbPath);
if ($storedEnc === false) { if ($storedEnc === false) {
if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); } flock($lockFp, LOCK_UN);
throw new Exception("Could not read stored DB"); fclose($lockFp);
throw new Exception('Could not read stored DB');
} }
try { try {
$decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey); $decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey);
@ -481,45 +490,32 @@ function qdb_open(bool $writable = false): array {
throw $e; throw $e;
} }
if (file_put_contents($tmpDb, $decrypted) === false) { if (file_put_contents($tmpDb, $decrypted) === false) {
if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); } flock($lockFp, LOCK_UN);
throw new Exception("Could not write temp DB"); fclose($lockFp);
throw new Exception('Could not write temp DB');
} }
} else { } else {
$pdo = new PDO("sqlite:$tmpDb"); $pdo = new PDO("sqlite:$tmpDb");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec($sql); $pdo->exec($sql);
$pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";"); qdb_set_schema_version($pdo, QDB_VERSION);
$pdo = null; $pdo = null;
} }
$pdo = new PDO("sqlite:$tmpDb"); $pdo = new PDO("sqlite:$tmpDb");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("PRAGMA foreign_keys = ON;"); qdb_set_foreign_keys($pdo, true);
$schemaChanged = qdb_apply_migrations($pdo, $sql); qdb_apply_migrations($pdo, $sql);
// Read-only opens discard the temp file; persist one-off migrations to the master DB.
if ($schemaChanged && !$writable) {
$migrateLock = qdb_acquire_lock();
try {
qdb_save($tmpDb, $migrateLock);
} catch (Throwable $e) {
@unlink($tmpDb);
flock($migrateLock, LOCK_UN);
fclose($migrateLock);
throw $e;
}
return qdb_open(false);
}
return [$pdo, $tmpDb, $lockFp]; return [$pdo, $tmpDb, $lockFp];
} }
/**
* Encrypt the temp DB and atomically write it back to the master path.
* Releases the lock.
*/
function qdb_save(string $tmpDb, $lockFp): void { function qdb_save(string $tmpDb, $lockFp): void {
if (qdb_is_mysql()) {
return;
}
$masterKey = get_master_key_bytes(); $masterKey = get_master_key_bytes();
try { try {
$ck = new PDO('sqlite:' . $tmpDb); $ck = new PDO('sqlite:' . $tmpDb);
@ -528,17 +524,19 @@ function qdb_save(string $tmpDb, $lockFp): void {
// best-effort before reading plaintext bytes // best-effort before reading plaintext bytes
} }
$plainDb = file_get_contents($tmpDb); $plainDb = file_get_contents($tmpDb);
if ($plainDb === false) throw new Exception("Could not read temp DB for save"); if ($plainDb === false) {
throw new Exception('Could not read temp DB for save');
}
$enc = aes256_cbc_encrypt_bytes($plainDb, $masterKey); $enc = aes256_cbc_encrypt_bytes($plainDb, $masterKey);
$dbPath = QDB_PATH; $dbPath = QDB_PATH;
$tmpEncrypted = tempnam(dirname($dbPath), 'enc_qdb_'); $tmpEncrypted = tempnam(dirname($dbPath), 'enc_qdb_');
if (file_put_contents($tmpEncrypted, $enc) === false) { if (file_put_contents($tmpEncrypted, $enc) === false) {
throw new Exception("Could not write encrypted DB"); throw new Exception('Could not write encrypted DB');
} }
if (!@rename($tmpEncrypted, $dbPath)) { if (!@rename($tmpEncrypted, $dbPath)) {
if (!@copy($tmpEncrypted, $dbPath) || !@unlink($tmpEncrypted)) { if (!@copy($tmpEncrypted, $dbPath) || !@unlink($tmpEncrypted)) {
throw new Exception("Could not save encrypted DB (rename/copy failed)"); throw new Exception('Could not save encrypted DB (rename/copy failed)');
} }
} }
@chmod($dbPath, 0644); @chmod($dbPath, 0644);
@ -553,10 +551,10 @@ function qdb_save(string $tmpDb, $lockFp): void {
qdb_read_cache_invalidate(); qdb_read_cache_invalidate();
} }
/**
* Discard changes -- clean up temp file and release lock without saving.
*/
function qdb_discard(string $tmpDb, $lockFp): void { function qdb_discard(string $tmpDb, $lockFp): void {
if (qdb_is_mysql()) {
return;
}
if (defined('QDB_READONLY_CACHE_HANDLE') && $tmpDb === QDB_READONLY_CACHE_HANDLE) { if (defined('QDB_READONLY_CACHE_HANDLE') && $tmpDb === QDB_READONLY_CACHE_HANDLE) {
return; return;
} }
@ -567,14 +565,7 @@ function qdb_discard(string $tmpDb, $lockFp): void {
} }
} }
/** /** @return array{0: PDO, 1: string, 2: resource|null} */
* Open a read-only DB connection; on failure logs and returns a JSON error response.
*
* @return array{0: PDO, 1: string, 2: resource|null}|null
*/
/**
* @return array{0: PDO, 1: string, 2: resource|null}
*/
function qdb_open_read_or_fail(): array { function qdb_open_read_or_fail(): array {
try { try {
return qdb_open(false); return qdb_open(false);
@ -584,9 +575,7 @@ function qdb_open_read_or_fail(): array {
} }
} }
/** /** @return array{0: PDO, 1: string, 2: resource|null} */
* @return array{0: PDO, 1: string, 2: resource|null}
*/
function qdb_open_write_or_fail(): array { function qdb_open_write_or_fail(): array {
try { try {
return qdb_open(true); return qdb_open(true);

View File

@ -181,10 +181,10 @@ case 'DELETE':
json_success(['deleted' => false, 'retired' => true, 'structureRevision' => $newRev]); json_success(['deleted' => false, 'retired' => true, 'structureRevision' => $newRev]);
break; break;
} }
$pdo->exec('PRAGMA foreign_keys = OFF;'); qdb_set_foreign_keys($pdo, false);
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $id]); $pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM answer_option WHERE answerOptionID = :id')->execute([':id' => $id]); $pdo->prepare('DELETE FROM answer_option WHERE answerOptionID = :id')->execute([':id' => $id]);
$pdo->exec('PRAGMA foreign_keys = ON;'); qdb_set_foreign_keys($pdo, true);
$pdo->commit(); $pdo->commit();
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true, 'retired' => false, 'structureRevision' => $newRev]); json_success(['deleted' => true, 'retired' => false, 'structureRevision' => $newRev]);

View File

@ -180,15 +180,19 @@ if ($method === 'POST') {
$glassByParent = []; $glassByParent = [];
$submittedQuestionIds = []; $submittedQuestionIds = [];
$answerInsert = $pdo->prepare(" $answerInsert = $pdo->prepare('
INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) '
ON CONFLICT(clientCode, questionID) DO UPDATE SET . qdb_upsert_update(
answerOptionID = excluded.answerOptionID, ['clientCode', 'questionID'],
freeTextValue = excluded.freeTextValue, [
numericValue = excluded.numericValue, 'answerOptionID' => 'excluded.answerOptionID',
answeredAt = excluded.answeredAt 'freeTextValue' => 'excluded.freeTextValue',
"); 'numericValue' => 'excluded.numericValue',
'answeredAt' => 'excluded.answeredAt',
]
)
);
$answerDelete = $pdo->prepare( $answerDelete = $pdo->prepare(
'DELETE FROM client_answer WHERE clientCode = :cc AND questionID = :qid' 'DELETE FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
); );
@ -298,16 +302,20 @@ if ($method === 'POST') {
? qdb_compute_questionnaire_score_from_manifest($pdo, $clientCode, $submitManifest) ? qdb_compute_questionnaire_score_from_manifest($pdo, $clientCode, $submitManifest)
: qdb_compute_questionnaire_score($pdo, $clientCode, $qnID); : qdb_compute_questionnaire_score($pdo, $clientCode, $qnID);
$pdo->prepare(" $pdo->prepare('
INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints) INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :sp) VALUES (:cc, :qn, :abc, \'completed\', :sa, :ca, :sp) '
ON CONFLICT(clientCode, questionnaireID) DO UPDATE SET . qdb_upsert_update(
assignedByCoach = excluded.assignedByCoach, ['clientCode', 'questionnaireID'],
status = 'completed', [
startedAt = COALESCE(excluded.startedAt, startedAt), 'assignedByCoach' => 'excluded.assignedByCoach',
completedAt = excluded.completedAt, 'status' => "'completed'",
sumPoints = excluded.sumPoints 'startedAt' => 'COALESCE(excluded.startedAt, startedAt)',
")->execute([ 'completedAt' => 'excluded.completedAt',
'sumPoints' => 'excluded.sumPoints',
]
)
)->execute([
':cc' => $clientCode, ':cc' => $clientCode,
':qn' => $qnID, ':qn' => $qnID,
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null, ':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,

View File

@ -7,21 +7,70 @@ if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
} }
$dbPath = QDB_PATH;
$backupDir = QDB_UPLOADS_DIR . '/backups'; $backupDir = QDB_UPLOADS_DIR . '/backups';
if (!file_exists($dbPath)) {
json_error('NOT_FOUND', 'No database to back up', 404);
}
if (!is_dir($backupDir)) { if (!is_dir($backupDir)) {
if (!mkdir($backupDir, 0755, true) && !is_dir($backupDir)) { if (!mkdir($backupDir, 0755, true) && !is_dir($backupDir)) {
json_error('SERVER_ERROR', 'Could not create backups directory', 500); json_error('SERVER_ERROR', 'Could not create backups directory', 500);
} }
} }
$timestamp = date('Y-m-d_H-i-s'); $timestamp = date('Y-m-d_H-i-s');
$filename = "questionnaire_database.$timestamp";
if (qdb_is_mysql()) {
[$host, $port] = [qdb_env_get('QDB_MYSQL_HOST') ?: '127.0.0.1', qdb_env_get('QDB_MYSQL_PORT') ?: '3306'];
[$user, $pass] = qdb_mysql_credentials();
$db = qdb_env_get('QDB_MYSQL_DATABASE') ?: 'nat-as-db';
$filename = "questionnaire_mysql_{$timestamp}.sql";
$backupFile = "$backupDir/$filename";
if (!function_exists('proc_open')) {
json_error('SERVER_ERROR', 'MySQL backup is unavailable: proc_open is disabled', 500);
}
$command = [
'mysqldump',
'--single-transaction',
'--routines',
'--triggers',
"--host={$host}",
"--port={$port}",
"--user={$user}",
$db,
];
$environment = getenv();
if (!is_array($environment)) {
$environment = [];
}
if ($pass !== '') {
$environment['MYSQL_PWD'] = $pass;
}
$descriptors = [
0 => ['file', '/dev/null', 'r'],
1 => ['file', $backupFile, 'wb'],
2 => ['pipe', 'w'],
];
$process = @proc_open($command, $descriptors, $pipes, null, $environment);
if (!is_resource($process)) {
@unlink($backupFile);
json_error('SERVER_ERROR', 'MySQL backup failed to start mysqldump', 500);
}
$errorOutput = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$code = proc_close($process);
if ($code !== 0 || !is_file($backupFile) || filesize($backupFile) === 0) {
@unlink($backupFile);
$detail = trim((string)$errorOutput);
json_error('SERVER_ERROR', 'MySQL backup failed' . ($detail !== '' ? ": {$detail}" : ''), 500);
}
@chmod($backupFile, 0600);
json_success(['filename' => $filename]);
}
$dbPath = QDB_PATH;
if (!file_exists($dbPath)) {
json_error('NOT_FOUND', 'No database to back up', 404);
}
$filename = "questionnaire_database.$timestamp";
$backupFile = "$backupDir/$filename"; $backupFile = "$backupDir/$filename";
if (!copy($dbPath, $backupFile)) { if (!copy($dbPath, $backupFile)) {

View File

@ -107,9 +107,9 @@ case 'POST':
$replaceIfExists = !empty($body['replaceIfExists']); $replaceIfExists = !empty($body['replaceIfExists']);
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try { try {
$pdo->exec('PRAGMA foreign_keys = OFF;'); qdb_set_foreign_keys($pdo, false);
$result = qdb_import_questionnaires_bundle($pdo, $bundle, $replaceIfExists); $result = qdb_import_questionnaires_bundle($pdo, $bundle, $replaceIfExists);
$pdo->exec('PRAGMA foreign_keys = ON;'); qdb_set_foreign_keys($pdo, true);
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success($result); json_success($result);
} catch (Throwable $e) { } catch (Throwable $e) {
@ -208,7 +208,7 @@ case 'DELETE':
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try { try {
$pdo->exec('PRAGMA foreign_keys = OFF;'); qdb_set_foreign_keys($pdo, false);
$pdo->beginTransaction(); $pdo->beginTransaction();
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id'); $qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
@ -231,7 +231,7 @@ case 'DELETE':
$pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $id]); $pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $id]);
$pdo->commit(); $pdo->commit();
$pdo->exec('PRAGMA foreign_keys = ON;'); qdb_set_foreign_keys($pdo, true);
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success([]); json_success([]);
} catch (Throwable $e) { } catch (Throwable $e) {

View File

@ -277,7 +277,7 @@ case 'DELETE':
break; break;
} }
$pdo->exec('PRAGMA foreign_keys = OFF;'); qdb_set_foreign_keys($pdo, false);
$aoIds = $pdo->prepare('SELECT answerOptionID FROM answer_option WHERE questionID = :id'); $aoIds = $pdo->prepare('SELECT answerOptionID FROM answer_option WHERE questionID = :id');
$aoIds->execute([':id' => $id]); $aoIds->execute([':id' => $id]);
foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) { foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) {
@ -287,7 +287,7 @@ case 'DELETE':
$pdo->prepare('DELETE FROM question_score_rule WHERE questionID = :id')->execute([':id' => $id]); $pdo->prepare('DELETE FROM question_score_rule WHERE questionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :id')->execute([':id' => $id]); $pdo->prepare('DELETE FROM question_translation WHERE questionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM question WHERE questionID = :id')->execute([':id' => $id]); $pdo->prepare('DELETE FROM question WHERE questionID = :id')->execute([':id' => $id]);
$pdo->exec('PRAGMA foreign_keys = ON;'); qdb_set_foreign_keys($pdo, true);
$pdo->commit(); $pdo->commit();
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success([ json_success([

View File

@ -232,12 +232,12 @@ case 'PUT':
if ($lang === QDB_SOURCE_LANGUAGE) { if ($lang === QDB_SOURCE_LANGUAGE) {
qdb_ensure_source_language($pdo); qdb_ensure_source_language($pdo);
} elseif (qdb_column_exists($pdo, 'language', 'enabled')) { } elseif (qdb_column_exists($pdo, 'language', 'enabled')) {
$pdo->prepare("INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1) $pdo->prepare('INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1) '
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name") . qdb_upsert_update(['languageCode'], ['name' => 'excluded.name', 'enabled' => '1']))
->execute([':lc' => $lang, ':n' => $name]); ->execute([':lc' => $lang, ':n' => $name]);
} else { } else {
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n) $pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n) '
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name") . qdb_upsert_update(['languageCode'], ['name' => 'excluded.name']))
->execute([':lc' => $lang, ':n' => $name]); ->execute([':lc' => $lang, ':n' => $name]);
} }
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);

View File

@ -255,11 +255,15 @@ function qdb_analytics_set_followup_note(PDO $pdo, array $tokenRec, string $clie
$pdo->prepare( $pdo->prepare(
'INSERT INTO client_followup_note (clientCode, note, updatedByUserID, updatedAt) 'INSERT INTO client_followup_note (clientCode, note, updatedByUserID, updatedAt)
VALUES (:cc, :n, :uid, :ts) VALUES (:cc, :n, :uid, :ts) '
ON CONFLICT(clientCode) DO UPDATE SET . qdb_upsert_update(
note = excluded.note, ['clientCode'],
updatedByUserID = excluded.updatedByUserID, [
updatedAt = excluded.updatedAt' 'note' => 'excluded.note',
'updatedByUserID' => 'excluded.updatedByUserID',
'updatedAt' => 'excluded.updatedAt',
]
)
)->execute([ )->execute([
':cc' => $clientCode, ':cc' => $clientCode,
':n' => $note, ':n' => $note,

View File

@ -575,12 +575,16 @@ function qdb_dev_persist_submission(
$answeredAt = $completedAt; $answeredAt = $completedAt;
$answerInsert = $pdo->prepare( $answerInsert = $pdo->prepare(
'INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) 'INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) '
ON CONFLICT(clientCode, questionID) DO UPDATE SET . qdb_upsert_update(
answerOptionID = excluded.answerOptionID, ['clientCode', 'questionID'],
freeTextValue = excluded.freeTextValue, [
numericValue = excluded.numericValue, 'answerOptionID' => 'excluded.answerOptionID',
answeredAt = excluded.answeredAt' 'freeTextValue' => 'excluded.freeTextValue',
'numericValue' => 'excluded.numericValue',
'answeredAt' => 'excluded.answeredAt',
]
)
); );
foreach ($answers as $a) { foreach ($answers as $a) {
@ -645,13 +649,17 @@ function qdb_dev_persist_submission(
$pdo->prepare( $pdo->prepare(
'INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints) 'INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
VALUES (:cc, :qn, :abc, \'completed\', :sa, :ca, :sp) VALUES (:cc, :qn, :abc, \'completed\', :sa, :ca, :sp) '
ON CONFLICT(clientCode, questionnaireID) DO UPDATE SET . qdb_upsert_update(
assignedByCoach = excluded.assignedByCoach, ['clientCode', 'questionnaireID'],
status = \'completed\', [
startedAt = COALESCE(excluded.startedAt, startedAt), 'assignedByCoach' => 'excluded.assignedByCoach',
completedAt = excluded.completedAt, 'status' => "'completed'",
sumPoints = excluded.sumPoints' 'startedAt' => 'COALESCE(excluded.startedAt, startedAt)',
'completedAt' => 'excluded.completedAt',
'sumPoints' => 'excluded.sumPoints',
]
)
)->execute([ )->execute([
':cc' => $clientCode, ':cc' => $clientCode,
':qn' => $qnID, ':qn' => $qnID,
@ -863,7 +871,7 @@ function qdb_wipe_all_data_except_admins(PDO $pdo): array
try { try {
// Interview data references clients/coaches; coach references supervisor. // Interview data references clients/coaches; coach references supervisor.
// Disable FK checks for this bulk wipe (same pattern as questionnaire delete). // Disable FK checks for this bulk wipe (same pattern as questionnaire delete).
$pdo->exec('PRAGMA foreign_keys = OFF'); qdb_set_foreign_keys($pdo, false);
$deleted['client_answers'] = (int)$pdo->exec('DELETE FROM client_answer'); $deleted['client_answers'] = (int)$pdo->exec('DELETE FROM client_answer');
if (qdb_table_exists($pdo, 'client_answer_submission')) { if (qdb_table_exists($pdo, 'client_answer_submission')) {
@ -891,14 +899,14 @@ function qdb_wipe_all_data_except_admins(PDO $pdo): array
$users->execute(); $users->execute();
$deleted['users'] = $users->rowCount(); $deleted['users'] = $users->rowCount();
$pdo->exec('PRAGMA foreign_keys = ON'); qdb_set_foreign_keys($pdo, true);
$pdo->commit(); $pdo->commit();
} catch (Throwable $e) { } catch (Throwable $e) {
if ($pdo->inTransaction()) { if ($pdo->inTransaction()) {
$pdo->rollBack(); $pdo->rollBack();
} }
try { try {
$pdo->exec('PRAGMA foreign_keys = ON'); qdb_set_foreign_keys($pdo, true);
} catch (Throwable $ignored) { } catch (Throwable $ignored) {
} }
throw $e; throw $e;

View File

@ -104,10 +104,14 @@ function qdb_save_structure_snapshot(PDO $pdo, string $qnID, int $revision, arra
} }
$pdo->prepare( $pdo->prepare(
'INSERT INTO questionnaire_structure_snapshot (questionnaireID, structureRevision, createdAt, manifestJson) 'INSERT INTO questionnaire_structure_snapshot (questionnaireID, structureRevision, createdAt, manifestJson)
VALUES (:qn, :rev, :ts, :mj) VALUES (:qn, :rev, :ts, :mj) '
ON CONFLICT(questionnaireID, structureRevision) DO UPDATE SET . qdb_upsert_update(
manifestJson = excluded.manifestJson, ['questionnaireID', 'structureRevision'],
createdAt = excluded.createdAt' [
'manifestJson' => 'excluded.manifestJson',
'createdAt' => 'excluded.createdAt',
]
)
)->execute([ )->execute([
':qn' => $qnID, ':qn' => $qnID,
':rev' => $revision, ':rev' => $revision,
@ -168,10 +172,7 @@ function qdb_option_has_client_data(PDO $pdo, string $answerOptionID): bool {
if ($stmt->fetchColumn()) { if ($stmt->fetchColumn()) {
return true; return true;
} }
$chk = $pdo->query( if (!qdb_table_exists($pdo, 'client_answer_submission')) {
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'client_answer_submission' LIMIT 1"
);
if (!$chk || !$chk->fetchColumn()) {
return false; return false;
} }
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
@ -187,10 +188,7 @@ function qdb_question_has_client_data(PDO $pdo, string $questionID): bool {
if ($stmt->fetchColumn()) { if ($stmt->fetchColumn()) {
return true; return true;
} }
$chk = $pdo->query( if (!qdb_table_exists($pdo, 'client_answer_submission')) {
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'client_answer_submission' LIMIT 1"
);
if (!$chk || !$chk->fetchColumn()) {
return false; return false;
} }
$stmt = $pdo->prepare( $stmt = $pdo->prepare(

View File

@ -12,6 +12,10 @@ define('QDB_READ_GENERATION_FILE', QDB_UPLOADS_DIR . '/.qdb_read_generation');
* @return array{0: PDO, 1: string, 2: null} * @return array{0: PDO, 1: string, 2: null}
*/ */
function qdb_read_db_open(): array { function qdb_read_db_open(): array {
if (qdb_is_mysql()) {
return qdb_mysql_open();
}
$signature = qdb_read_cache_signature(); $signature = qdb_read_cache_signature();
$cached = qdb_read_cache_get($signature); $cached = qdb_read_cache_get($signature);
if ($cached !== null) { if ($cached !== null) {

View File

@ -383,12 +383,16 @@ function qdb_recompute_profile_scores_for_client(PDO $pdo, string $clientCode, ?
$upsert = $pdo->prepare( $upsert = $pdo->prepare(
'INSERT INTO client_scoring_profile_result 'INSERT INTO client_scoring_profile_result
(clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot) (clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot)
VALUES (:cc, :pid, :wt, :band, :at, :snap) VALUES (:cc, :pid, :wt, :band, :at, :snap) '
ON CONFLICT(clientCode, profileID) DO UPDATE SET . qdb_upsert_update(
weightedTotal = excluded.weightedTotal, ['clientCode', 'profileID'],
band = excluded.band, [
computedAt = excluded.computedAt, 'weightedTotal' => 'excluded.weightedTotal',
questionnaireSnapshot = excluded.questionnaireSnapshot' 'band' => 'excluded.band',
'computedAt' => 'excluded.computedAt',
'questionnaireSnapshot' => 'excluded.questionnaireSnapshot',
]
)
); );
foreach ($profiles as $profile) { foreach ($profiles as $profile) {

View File

@ -97,8 +97,8 @@ function qdb_settings_save_on_pdo(PDO $pdo, array $settings): void
qdb_ensure_system_setting_table($pdo); qdb_ensure_system_setting_table($pdo);
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'INSERT INTO system_setting (settingKey, settingValue) 'INSERT INTO system_setting (settingKey, settingValue)
VALUES (:k, :v) VALUES (:k, :v) '
ON CONFLICT(settingKey) DO UPDATE SET settingValue = excluded.settingValue' . qdb_upsert_update(['settingKey'], ['settingValue' => 'excluded.settingValue'])
); );
foreach (qdb_settings_keys() as $key) { foreach (qdb_settings_keys() as $key) {
$stmt->execute([':k' => $key, ':v' => (string)(int)($settings[$key] ?? 0)]); $stmt->execute([':k' => $key, ':v' => (string)(int)($settings[$key] ?? 0)]);

226
lib/sql_dialect.php Normal file
View File

@ -0,0 +1,226 @@
<?php
/** Database driver helpers (SQLite legacy + MySQL). */
function qdb_driver(): string {
if (isset($GLOBALS['qdb_driver_cache'])) {
return (string)$GLOBALS['qdb_driver_cache'];
}
$GLOBALS['qdb_driver_cache'] = qdb_driver_resolve();
return (string)$GLOBALS['qdb_driver_cache'];
}
function qdb_reset_driver_cache(): void {
unset($GLOBALS['qdb_driver_cache']);
}
function qdb_driver_resolve(): string {
$configured = strtolower(trim((string)(qdb_env_get('QDB_DRIVER') ?? '')));
if ($configured === 'mysql' || $configured === 'sqlite') {
$driver = $configured;
return $driver;
}
if (qdb_env_get('QDB_MYSQL_HOST') || qdb_env_get('QDB_MYSQL_DATABASE')) {
$driver = 'mysql';
return $driver;
}
$driver = 'sqlite';
return $driver;
}
function qdb_is_mysql(): bool {
return qdb_driver() === 'mysql';
}
function qdb_is_sqlite(): bool {
return qdb_driver() === 'sqlite';
}
function qdb_schema_file(): string {
return qdb_is_mysql()
? __DIR__ . '/../schema.mysql.sql'
: QDB_SCHEMA;
}
function qdb_mysql_dsn(): string {
$host = qdb_env_get('QDB_MYSQL_HOST') ?: '127.0.0.1';
$port = qdb_env_get('QDB_MYSQL_PORT') ?: '3306';
$db = qdb_env_get('QDB_MYSQL_DATABASE') ?: 'nat-as-db';
return "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
}
function qdb_mysql_credentials(): array {
return [
qdb_env_get('QDB_MYSQL_USER') ?: 'nat_as_db',
qdb_env_get('QDB_MYSQL_PASSWORD') ?: '',
];
}
/** @return array{0: PDO, 1: string, 2: null} */
function qdb_mysql_open(): array {
static $pdo = null;
if ($pdo === null) {
[$user, $pass] = qdb_mysql_credentials();
$pdo = new PDO(qdb_mysql_dsn(), $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
}
return [$pdo, '', null];
}
function qdb_set_foreign_keys(PDO $pdo, bool $enabled): void {
if (qdb_is_mysql()) {
$pdo->exec('SET FOREIGN_KEY_CHECKS = ' . ($enabled ? '1' : '0'));
return;
}
$pdo->exec('PRAGMA foreign_keys = ' . ($enabled ? 'ON' : 'OFF') . ';');
}
function qdb_table_exists(PDO $pdo, string $table): bool {
if (qdb_is_mysql()) {
$stmt = $pdo->prepare(
'SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = :t LIMIT 1'
);
$stmt->execute([':t' => $table]);
return (bool)$stmt->fetchColumn();
}
$stmt = $pdo->prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :t LIMIT 1"
);
$stmt->execute([':t' => $table]);
return (bool)$stmt->fetchColumn();
}
function qdb_column_exists(PDO $pdo, string $table, string $column): bool {
if (qdb_is_mysql()) {
$stmt = $pdo->prepare(
'SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = :t AND column_name = :c LIMIT 1'
);
$stmt->execute([':t' => $table, ':c' => $column]);
return (bool)$stmt->fetchColumn();
}
$stmt = $pdo->query('PRAGMA table_info(' . $table . ')');
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) {
if (($col['name'] ?? '') === $column) {
return true;
}
}
return false;
}
function qdb_schema_version(PDO $pdo): int {
if (qdb_is_mysql()) {
if (!qdb_table_exists($pdo, 'schema_version')) {
return 0;
}
return (int)$pdo->query('SELECT version FROM schema_version WHERE id = 1')->fetchColumn();
}
return (int)$pdo->query('PRAGMA user_version')->fetchColumn();
}
function qdb_set_schema_version(PDO $pdo, int $version): void {
if (qdb_is_mysql()) {
if (!qdb_table_exists($pdo, 'schema_version')) {
$pdo->exec(
'CREATE TABLE IF NOT EXISTS schema_version (
id INT NOT NULL PRIMARY KEY,
version INT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
$pdo->prepare(
'INSERT INTO schema_version (id, version) VALUES (1, :v)
ON DUPLICATE KEY UPDATE version = VALUES(version)'
)->execute([':v' => $version]);
return;
}
$pdo->exec('PRAGMA user_version = ' . (int)$version . ';');
}
function qdb_stmt_affected_rows(PDO $pdo, PDOStatement $stmt): int {
if (qdb_is_mysql()) {
return $stmt->rowCount();
}
return (int)$pdo->query('SELECT changes()')->fetchColumn();
}
function qdb_create_index_if_not_exists(PDO $pdo, string $name, string $table, string $columnsSql): void {
if (qdb_is_mysql()) {
if (!qdb_table_exists($pdo, $table)) {
return;
}
$stmt = $pdo->prepare(
'SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = :t AND index_name = :i LIMIT 1'
);
$stmt->execute([':t' => $table, ':i' => $name]);
if ($stmt->fetchColumn()) {
return;
}
$pdo->exec("CREATE INDEX `{$name}` ON `{$table}` ({$columnsSql})");
return;
}
$pdo->exec("CREATE INDEX IF NOT EXISTS {$name} ON {$table}({$columnsSql})");
}
/**
* @param list<string> $conflictColumns
*/
function qdb_upsert_do_nothing(array $conflictColumns): string {
if (qdb_is_mysql()) {
$first = $conflictColumns[0];
return "ON DUPLICATE KEY UPDATE `{$first}` = `{$first}`";
}
return 'ON CONFLICT(' . implode(', ', $conflictColumns) . ') DO NOTHING';
}
/**
* @param list<string> $conflictColumns
* @param array<string, string> $assignments column => expression (use excluded.col on SQLite)
*/
function qdb_upsert_update(array $conflictColumns, array $assignments): string {
if (qdb_is_mysql()) {
$parts = [];
foreach ($assignments as $column => $expression) {
$mysqlExpr = preg_replace('/\bexcluded\.(\w+)\b/', 'VALUES($1)', $expression) ?? $expression;
$parts[] = "`{$column}` = {$mysqlExpr}";
}
return 'ON DUPLICATE KEY UPDATE ' . implode(', ', $parts);
}
$parts = [];
foreach ($assignments as $column => $expression) {
$parts[] = "{$column} = {$expression}";
}
return 'ON CONFLICT(' . implode(', ', $conflictColumns) . ') DO UPDATE SET ' . implode(', ', $parts);
}
function qdb_exec_schema_file(PDO $pdo, string $schemaPath): void {
$sql = file_get_contents($schemaPath);
if ($sql === false) {
throw new RuntimeException("Could not read schema: {$schemaPath}");
}
qdb_exec_schema_sql($pdo, $sql);
}
function qdb_exec_schema_sql(PDO $pdo, string $sql): void {
if (qdb_is_mysql()) {
foreach (qdb_split_sql_statements($sql) as $statement) {
$trimmed = trim($statement);
if ($trimmed === '' || str_starts_with($trimmed, '--')) {
continue;
}
$pdo->exec($trimmed);
}
return;
}
$pdo->exec($sql);
}
/** @return list<string> */
function qdb_split_sql_statements(string $sql): array {
$parts = preg_split('/;\s*\n/', $sql) ?: [];
return array_values(array_filter(array_map('trim', $parts), fn($s) => $s !== ''));
}

270
schema.mysql.sql Normal file
View File

@ -0,0 +1,270 @@
CREATE TABLE IF NOT EXISTS schema_version (
id INT NOT NULL PRIMARY KEY,
version INT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS users (
userID VARCHAR(64) NOT NULL PRIMARY KEY,
username VARCHAR(191) NOT NULL UNIQUE,
passwordHash VARCHAR(255) NOT NULL,
role VARCHAR(32) NOT NULL,
entityID VARCHAR(64) NOT NULL,
mustChangePassword TINYINT(1) NOT NULL DEFAULT 1,
createdAt BIGINT NOT NULL,
CONSTRAINT chk_users_role CHECK (role IN ('admin','supervisor','coach'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS admin (
adminID VARCHAR(64) NOT NULL PRIMARY KEY,
username VARCHAR(191) NOT NULL,
location VARCHAR(255) NOT NULL DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS supervisor (
supervisorID VARCHAR(64) NOT NULL PRIMARY KEY,
username VARCHAR(191) NOT NULL,
location VARCHAR(255) NOT NULL DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS coach (
coachID VARCHAR(64) NOT NULL PRIMARY KEY,
supervisorID VARCHAR(64) NOT NULL,
username VARCHAR(191) NOT NULL,
FOREIGN KEY(supervisorID) REFERENCES supervisor(supervisorID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS client (
clientCode VARCHAR(191) NOT NULL PRIMARY KEY,
coachID VARCHAR(64) NOT NULL,
archived TINYINT(1) NOT NULL DEFAULT 0,
archivedAt BIGINT NOT NULL DEFAULT 0,
FOREIGN KEY(coachID) REFERENCES coach(coachID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS questionnaire_category (
categoryKey VARCHAR(191) NOT NULL PRIMARY KEY,
label VARCHAR(255) NOT NULL DEFAULT '',
color VARCHAR(32) NOT NULL DEFAULT '#64748b',
orderIndex INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS questionnaire (
questionnaireID VARCHAR(191) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT '',
version VARCHAR(64) NOT NULL DEFAULT '',
state VARCHAR(64) NOT NULL DEFAULT '',
orderIndex INT NOT NULL DEFAULT 0,
showPoints TINYINT(1) NOT NULL DEFAULT 0,
conditionJson LONGTEXT NOT NULL,
categoryKey VARCHAR(191) NOT NULL DEFAULT '',
structureRevision INT NOT NULL DEFAULT 1,
structureChangedAt BIGINT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS questionnaire_structure_snapshot (
questionnaireID VARCHAR(191) NOT NULL,
structureRevision INT NOT NULL,
createdAt BIGINT NOT NULL,
manifestJson LONGTEXT NOT NULL,
PRIMARY KEY (questionnaireID, structureRevision),
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS question (
questionID VARCHAR(64) NOT NULL PRIMARY KEY,
questionnaireID VARCHAR(191) NOT NULL,
defaultText LONGTEXT NOT NULL,
type VARCHAR(64) NOT NULL DEFAULT '',
orderIndex INT NOT NULL DEFAULT 0,
isRequired TINYINT(1) NOT NULL DEFAULT 0,
configJson LONGTEXT NOT NULL,
retiredAt BIGINT NOT NULL DEFAULT 0,
retiredInRevision INT NOT NULL DEFAULT 0,
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS answer_option (
answerOptionID VARCHAR(64) NOT NULL PRIMARY KEY,
questionID VARCHAR(64) NOT NULL,
defaultText LONGTEXT NOT NULL,
points INT NOT NULL DEFAULT 0,
orderIndex INT NOT NULL DEFAULT 0,
nextQuestionId VARCHAR(64) NOT NULL DEFAULT '',
retiredAt BIGINT NOT NULL DEFAULT 0,
FOREIGN KEY(questionID) REFERENCES question(questionID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS client_answer (
clientCode VARCHAR(191) NOT NULL,
questionID VARCHAR(64) NOT NULL,
answerOptionID VARCHAR(64) NULL,
freeTextValue LONGTEXT NULL,
numericValue DOUBLE NULL,
answeredAt BIGINT NULL,
PRIMARY KEY (clientCode, questionID),
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
FOREIGN KEY(questionID) REFERENCES question(questionID),
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS completed_questionnaire (
clientCode VARCHAR(191) NOT NULL,
questionnaireID VARCHAR(191) NOT NULL,
assignedByCoach VARCHAR(64) NULL,
status VARCHAR(64) NOT NULL DEFAULT '',
startedAt BIGINT NULL,
completedAt BIGINT NULL,
sumPoints INT NULL,
PRIMARY KEY (clientCode, questionnaireID),
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS question_translation (
questionID VARCHAR(64) NOT NULL,
languageCode VARCHAR(16) NOT NULL,
text LONGTEXT NOT NULL,
PRIMARY KEY (questionID, languageCode),
FOREIGN KEY(questionID) REFERENCES question(questionID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS answer_option_translation (
answerOptionID VARCHAR(64) NOT NULL,
languageCode VARCHAR(16) NOT NULL,
text LONGTEXT NOT NULL,
PRIMARY KEY (answerOptionID, languageCode),
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS string_translation (
stringKey VARCHAR(191) NOT NULL,
languageCode VARCHAR(16) NOT NULL,
text LONGTEXT NOT NULL,
PRIMARY KEY (stringKey, languageCode)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS language (
languageCode VARCHAR(16) NOT NULL PRIMARY KEY,
name VARCHAR(191) NOT NULL DEFAULT '',
enabled TINYINT(1) NOT NULL DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS questionnaire_language_disable (
questionnaireID VARCHAR(191) NOT NULL,
languageCode VARCHAR(16) NOT NULL,
PRIMARY KEY (questionnaireID, languageCode),
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE,
FOREIGN KEY(languageCode) REFERENCES language(languageCode) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS session (
token VARCHAR(128) NOT NULL PRIMARY KEY,
userID VARCHAR(64) NOT NULL,
role VARCHAR(32) NOT NULL,
entityID VARCHAR(64) NOT NULL DEFAULT '',
createdAt BIGINT NOT NULL,
expiresAt BIGINT NOT NULL,
temp TINYINT(1) NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS questionnaire_submission (
submissionID VARCHAR(64) NOT NULL PRIMARY KEY,
clientCode VARCHAR(191) NOT NULL,
questionnaireID VARCHAR(191) NOT NULL,
version INT NOT NULL,
submittedAt BIGINT NOT NULL,
submittedByUserID VARCHAR(64) NOT NULL DEFAULT '',
submittedByRole VARCHAR(32) NOT NULL DEFAULT '',
assignedByCoach VARCHAR(64) NULL,
status VARCHAR(64) NOT NULL DEFAULT '',
startedAt BIGINT NULL,
completedAt BIGINT NULL,
sumPoints INT NULL,
structureRevision INT NOT NULL DEFAULT 1,
structureSnapshotJson LONGTEXT NOT NULL,
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID),
UNIQUE KEY uq_submission_client_qn_version (clientCode, questionnaireID, version),
KEY idx_submission_client_qn (clientCode, questionnaireID),
KEY idx_submission_client_time (clientCode, submittedAt)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS client_answer_submission (
submissionID VARCHAR(64) NOT NULL,
questionID VARCHAR(64) NOT NULL,
answerOptionID VARCHAR(64) NULL,
freeTextValue LONGTEXT NULL,
numericValue DOUBLE NULL,
answeredAt BIGINT NULL,
PRIMARY KEY (submissionID, questionID),
FOREIGN KEY(submissionID) REFERENCES questionnaire_submission(submissionID) ON DELETE CASCADE,
FOREIGN KEY(questionID) REFERENCES question(questionID),
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS client_followup_note (
clientCode VARCHAR(191) NOT NULL PRIMARY KEY,
note LONGTEXT NOT NULL,
updatedByUserID VARCHAR(64) NOT NULL DEFAULT '',
updatedAt BIGINT NOT NULL,
FOREIGN KEY(clientCode) REFERENCES client(clientCode)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS system_setting (
settingKey VARCHAR(191) NOT NULL PRIMARY KEY,
settingValue LONGTEXT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS question_score_rule (
ruleID VARCHAR(64) NOT NULL PRIMARY KEY,
questionID VARCHAR(64) NOT NULL,
scopeKey VARCHAR(191) NOT NULL DEFAULT '',
levelKey VARCHAR(191) NOT NULL,
points INT NOT NULL DEFAULT 0,
FOREIGN KEY(questionID) REFERENCES question(questionID) ON DELETE CASCADE,
UNIQUE KEY uq_score_rule_question_scope_level (questionID, scopeKey, levelKey),
KEY idx_score_rule_question (questionID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS scoring_profile (
profileID VARCHAR(64) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT '',
description LONGTEXT NOT NULL,
isActive TINYINT(1) NOT NULL DEFAULT 1,
greenMin INT NOT NULL DEFAULT 0,
greenMax INT NOT NULL DEFAULT 12,
yellowMin INT NOT NULL DEFAULT 13,
yellowMax INT NOT NULL DEFAULT 36,
redMin INT NOT NULL DEFAULT 37,
processCompleteBands LONGTEXT NOT NULL,
createdAt BIGINT NOT NULL DEFAULT 0,
updatedAt BIGINT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS scoring_profile_questionnaire (
profileID VARCHAR(64) NOT NULL,
questionnaireID VARCHAR(191) NOT NULL,
weight DOUBLE NOT NULL DEFAULT 1.0,
orderIndex INT NOT NULL DEFAULT 0,
PRIMARY KEY (profileID, questionnaireID),
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE,
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS client_scoring_profile_result (
clientCode VARCHAR(191) NOT NULL,
profileID VARCHAR(64) NOT NULL,
weightedTotal DOUBLE NOT NULL DEFAULT 0,
band VARCHAR(32) NOT NULL DEFAULT '',
computedAt BIGINT NOT NULL DEFAULT 0,
questionnaireSnapshot LONGTEXT NOT NULL,
coachBand VARCHAR(32) NOT NULL DEFAULT '',
coachReviewedAt BIGINT NOT NULL DEFAULT 0,
coachReviewedByUserID VARCHAR(64) NOT NULL DEFAULT '',
PRIMARY KEY (clientCode, profileID),
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE,
FOREIGN KEY(profileID) REFERENCES scoring_profile(profileID) ON DELETE CASCADE,
KEY idx_client_profile_result_profile (profileID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -16,8 +16,8 @@ final class DbInitTest extends QdbTestCase
qdb_discard($t1, $l1); qdb_discard($t1, $l1);
[$pdo2, $t2, $l2] = qdb_open(true); [$pdo2, $t2, $l2] = qdb_open(true);
$pdo2->exec("INSERT INTO language (languageCode, name) VALUES ('fr', 'French') $pdo2->exec("INSERT INTO language (languageCode, name) VALUES ('fr', 'French') "
ON CONFLICT(languageCode) DO NOTHING"); . qdb_upsert_do_nothing(['languageCode']));
qdb_save($t2, $l2); qdb_save($t2, $l2);
[$pdo3, $t3, $l3] = qdb_open(false); [$pdo3, $t3, $l3] = qdb_open(false);
@ -35,6 +35,26 @@ final class DbInitTest extends QdbTestCase
qdb_discard($tmp, $lock); qdb_discard($tmp, $lock);
} }
public function testSchemaSqlContentIsExecutedDirectly(): void
{
$pdo = new \PDO('sqlite::memory:');
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
qdb_exec_schema_sql($pdo, 'CREATE TABLE schema_sql_test (id INTEGER PRIMARY KEY);');
$this->assertTrue(qdb_table_exists($pdo, 'schema_sql_test'));
}
public function testRuntimeDriverOverrideWinsOverConfiguration(): void
{
qdb_env_override('QDB_DRIVER', 'mysql');
$this->assertSame('mysql', qdb_env_get('QDB_DRIVER'));
$this->assertTrue(qdb_is_mysql());
qdb_env_override('QDB_DRIVER', 'sqlite');
$this->assertTrue(qdb_is_sqlite());
}
public function testMigrationsRecreateDroppedSubmissionTables(): void public function testMigrationsRecreateDroppedSubmissionTables(): void
{ {
[$pdo, $tmp, $lock] = qdb_open(true); [$pdo, $tmp, $lock] = qdb_open(true);

View File

@ -22,8 +22,9 @@ require dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/lib/response.php'; require_once dirname(__DIR__) . '/lib/response.php';
require_once dirname(__DIR__) . '/common.php'; require_once dirname(__DIR__) . '/common.php';
qdb_env_override('QDB_DRIVER', 'sqlite');
// Always use the test key, even when .env defines QDB_MASTER_KEY (avoids decrypt/seed drift). // Always use the test key, even when .env defines QDB_MASTER_KEY (avoids decrypt/seed drift).
qdb_env_set('QDB_MASTER_KEY', $testMasterKey); qdb_env_override('QDB_MASTER_KEY', $testMasterKey);
$GLOBALS['qdb_dotenv']['QDB_MASTER_KEY'] = $testMasterKey;
require_once dirname(__DIR__) . '/db_init.php'; require_once dirname(__DIR__) . '/db_init.php';

40
tests/mysql_smoke.php Normal file
View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/lib/response.php';
require_once dirname(__DIR__) . '/common.php';
qdb_env_override('QDB_DRIVER', 'mysql');
require_once dirname(__DIR__) . '/db_init.php';
[$pdo] = qdb_open(true);
foreach (['users', 'questionnaire', 'questionnaire_submission', 'system_setting'] as $table) {
if (!qdb_table_exists($pdo, $table)) {
throw new RuntimeException("MySQL smoke test: missing table {$table}");
}
}
if (qdb_schema_version($pdo) !== QDB_VERSION) {
throw new RuntimeException('MySQL smoke test: incorrect schema version');
}
// Schema application must be safe to retry after an interrupted or concurrent bootstrap.
qdb_exec_schema_file($pdo, qdb_schema_file());
$pdo->exec("DELETE FROM language WHERE languageCode = 'ci-smoke'");
$upsert = $pdo->prepare(
'INSERT INTO language (languageCode, name, enabled) VALUES (:code, :name, 1) '
. qdb_upsert_update(['languageCode'], ['name' => 'excluded.name'])
);
$upsert->execute([':code' => 'ci-smoke', ':name' => 'First']);
$upsert->execute([':code' => 'ci-smoke', ':name' => 'Updated']);
$name = $pdo->query("SELECT name FROM language WHERE languageCode = 'ci-smoke'")->fetchColumn();
if ($name !== 'Updated') {
throw new RuntimeException('MySQL smoke test: upsert failed');
}
$pdo->exec("DELETE FROM language WHERE languageCode = 'ci-smoke'");
fwrite(STDOUT, "MySQL smoke test passed\n");