This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal 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=
|
||||||
27
.github/workflows/phpunit.yml
vendored
27
.github/workflows/phpunit.yml
vendored
@ -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
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
82
cli/migrate_sqlite_to_mysql.php
Normal file
82
cli/migrate_sqlite_to_mysql.php
Normal 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";
|
||||||
51
common.php
51
common.php
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
189
db_init.php
189
db_init.php
@ -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);
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
226
lib/sql_dialect.php
Normal 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
270
schema.mysql.sql
Normal 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;
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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
40
tests/mysql_smoke.php
Normal 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");
|
||||||
Reference in New Issue
Block a user