From 24080bb8b9b84b2bc300a727105abd602f44310f Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Wed, 27 May 2026 20:03:47 +0200 Subject: [PATCH] implemented incremental schema migration for questionnaire, added categoryKey column if missing, and improved error handling in questionnaire retrieval --- db_init.php | 80 ++++++++++++++++++++++++++++--------- handlers/questionnaires.php | 38 ++++++++++-------- 2 files changed, 83 insertions(+), 35 deletions(-) diff --git a/db_init.php b/db_init.php index 7886281..0ffab33 100644 --- a/db_init.php +++ b/db_init.php @@ -8,6 +8,54 @@ define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock'); define('QDB_SCHEMA', __DIR__ . '/schema.sql'); define('QDB_VERSION', 4); +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. + */ +function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { + $changed = false; + + if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) { + $pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''"); + $changed = true; + } + + $currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); + if ($currentVersion < QDB_VERSION) { + $pdo->exec('PRAGMA foreign_keys = OFF;'); + $pdo->exec($schemaSql); + if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) { + $pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''"); + } + $pdo->exec('PRAGMA user_version = ' . QDB_VERSION . ';'); + $pdo->exec('PRAGMA foreign_keys = ON;'); + $changed = true; + } + + return $changed; +} + +function qdb_acquire_lock() { + $lockFp = fopen(QDB_LOCK, 'c'); + if ($lockFp === false) { + throw new Exception('Could not open lock file'); + } + if (!flock($lockFp, LOCK_EX)) { + fclose($lockFp); + throw new Exception('Could not acquire lock'); + } + return $lockFp; +} + /** * Decrypt the master DB file into a temp SQLite file, or create a fresh one * from schema.sql if no DB exists yet. @@ -67,26 +115,20 @@ function qdb_open(bool $writable = false): array { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->exec("PRAGMA foreign_keys = ON;"); - // Schema upgrade: apply CREATE TABLE IF NOT EXISTS for any missing tables - $currentVersion = (int)$pdo->query("PRAGMA user_version")->fetchColumn(); - if ($currentVersion < QDB_VERSION) { - $pdo->exec("PRAGMA foreign_keys = OFF;"); - $pdo->exec($sql); - if ($currentVersion < 4) { - $cols = $pdo->query("PRAGMA table_info(questionnaire)")->fetchAll(PDO::FETCH_ASSOC); - $hasCategory = false; - foreach ($cols as $col) { - if (($col['name'] ?? '') === 'categoryKey') { - $hasCategory = true; - break; - } - } - if (!$hasCategory) { - $pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''"); - } + $schemaChanged = 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; } - $pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";"); - $pdo->exec("PRAGMA foreign_keys = ON;"); + return qdb_open(false); } return [$pdo, $tmpDb, $lockFp]; diff --git a/handlers/questionnaires.php b/handlers/questionnaires.php index 5e49f9c..56d5b50 100644 --- a/handlers/questionnaires.php +++ b/handlers/questionnaires.php @@ -6,23 +6,29 @@ switch ($method) { case 'GET': [$pdo, $tmpDb, $lockFp] = qdb_open(false); - $rows = $pdo->query(" - SELECT q.questionnaireID, q.name, q.version, q.state, - q.orderIndex, q.showPoints, q.conditionJson, q.categoryKey, - COUNT(qu.questionID) AS questionCount - FROM questionnaire q - LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID - GROUP BY q.questionnaireID - ORDER BY q.orderIndex, q.name - ")->fetchAll(PDO::FETCH_ASSOC); - foreach ($rows as &$r) { - $r['orderIndex'] = (int)$r['orderIndex']; - $r['showPoints'] = (int)$r['showPoints']; - $r['conditionJson'] = $r['conditionJson'] ?: '{}'; + try { + $rows = $pdo->query(" + SELECT q.questionnaireID, q.name, q.version, q.state, + q.orderIndex, q.showPoints, q.conditionJson, q.categoryKey, + COUNT(qu.questionID) AS questionCount + FROM questionnaire q + LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID + GROUP BY q.questionnaireID + ORDER BY q.orderIndex, q.name + ")->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as &$r) { + $r['orderIndex'] = (int)$r['orderIndex']; + $r['showPoints'] = (int)$r['showPoints']; + $r['conditionJson'] = $r['conditionJson'] ?: '{}'; + } + unset($r); + qdb_discard($tmpDb, $lockFp); + json_success(['questionnaires' => $rows]); + } catch (Throwable $e) { + qdb_discard($tmpDb, $lockFp); + error_log('questionnaires GET: ' . $e->getMessage()); + json_error('SERVER_ERROR', 'Server error', 500); } - unset($r); - qdb_discard($tmpDb, $lockFp); - json_success(['questionnaires' => $rows]); break; case 'POST':