diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6fabb56 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index bf175d4..dc9073c 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -9,6 +9,22 @@ jobs: test: 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: - uses: actions/checkout@v4 @@ -16,7 +32,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.2' - extensions: json, openssl, pdo, pdo_sqlite, zip + extensions: json, openssl, pdo, pdo_mysql, pdo_sqlite, zip coverage: pcov tools: composer:v2 @@ -25,3 +41,12 @@ jobs: - name: Run tests with coverage floor 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 diff --git a/cli/diagnose_db.php b/cli/diagnose_db.php index 347d08a..135e5b2 100644 --- a/cli/diagnose_db.php +++ b/cli/diagnose_db.php @@ -36,11 +36,37 @@ echo ".env: " . ($envPath && is_readable($envPath) ? "readable" : "MISSING or no $keyEnv = getenv('QDB_MASTER_KEY'); if ($keyEnv === false || $keyEnv === '') { 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); -echo " parses as base64: " . ($b64 !== false && strlen($b64) > 0 ? 'yes (' . strlen($b64) . ' bytes)' : 'no (uses first 32 ASCII chars)') . "\n"; + +if (qdb_is_mysql()) { + 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; if ($dbPath !== QDB_PATH && !is_file($dbPath)) { diff --git a/cli/migrate_sqlite_to_mysql.php b/cli/migrate_sqlite_to_mysql.php new file mode 100644 index 0000000..249a43a --- /dev/null +++ b/cli/migrate_sqlite_to_mysql.php @@ -0,0 +1,82 @@ +#!/usr/bin/env php + 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"; diff --git a/common.php b/common.php index 00c2445..671eca2 100644 --- a/common.php +++ b/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). */ 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()); if (isset($fileVars[$name]) && $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. */ function qdb_load_dotenv(string $path): void { 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 { if (qdb_column_exists($pdo, 'language', 'enabled')) { - $pdo->prepare("INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1) - ON CONFLICT(languageCode) DO UPDATE SET enabled = 1") + $pdo->prepare('INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1) ' + . qdb_upsert_update(['languageCode'], ['enabled' => '1', 'name' => 'excluded.name'])) ->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']); return; } - $pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n) - ON CONFLICT(languageCode) DO NOTHING") + $pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n) ' + . qdb_upsert_do_nothing(['languageCode'])) ->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']); } /** @return list */ function qdb_load_languages(PDO $pdo): array { - qdb_ensure_source_language($pdo); $hasEnabled = qdb_column_exists($pdo, 'language', 'enabled'); if ($hasEnabled) { $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')) { throw new RuntimeException('Language enablement is not available on this database'); } - $pdo->prepare('UPDATE language SET enabled = :en WHERE languageCode = :lc') - ->execute([':en' => $enabled ? 1 : 0, ':lc' => $languageCode]); - if ($pdo->query('SELECT changes()')->fetchColumn() == 0) { + $stmt = $pdo->prepare('UPDATE language SET enabled = :en WHERE languageCode = :lc'); + $stmt->execute([':en' => $enabled ? 1 : 0, ':lc' => $languageCode]); + if (qdb_stmt_affected_rows($pdo, $stmt) === 0) { throw new InvalidArgumentException('Language not found'); } } @@ -496,7 +507,7 @@ function qdb_set_questionnaire_language_disabled( if ($disabled) { $pdo->prepare( '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]); 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. */ function qdb_put_translation(PDO $pdo, string $type, string $id, string $lang, string $text): void { if ($type === 'question') { - $pdo->prepare("INSERT INTO question_translation (questionID, languageCode, text) - VALUES (:id, :lang, :t) - ON CONFLICT(questionID, languageCode) DO UPDATE SET text = excluded.text") + $pdo->prepare('INSERT INTO question_translation (questionID, languageCode, text) + VALUES (:id, :lang, :t) ' + . qdb_upsert_update(['questionID', 'languageCode'], ['text' => 'excluded.text'])) ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); if ($lang === QDB_SOURCE_LANGUAGE) { $pdo->prepare("UPDATE question SET defaultText = :t WHERE questionID = :id") ->execute([':t' => $text, ':id' => $id]); } } elseif ($type === 'answer_option') { - $pdo->prepare("INSERT INTO answer_option_translation (answerOptionID, languageCode, text) - VALUES (:id, :lang, :t) - ON CONFLICT(answerOptionID, languageCode) DO UPDATE SET text = excluded.text") + $pdo->prepare('INSERT INTO answer_option_translation (answerOptionID, languageCode, text) + VALUES (:id, :lang, :t) ' + . qdb_upsert_update(['answerOptionID', 'languageCode'], ['text' => 'excluded.text'])) ->execute([':id' => $id, ':lang' => $lang, ':t' => $text]); // defaultText holds optionKey; German label lives only in answer_option_translation. } elseif ($type === 'string' || $type === 'app_string') { - $pdo->prepare("INSERT INTO string_translation (stringKey, languageCode, text) - VALUES (:id, :lang, :t) - ON CONFLICT(stringKey, languageCode) DO UPDATE SET text = excluded.text") + $pdo->prepare('INSERT INTO string_translation (stringKey, languageCode, text) + VALUES (:id, :lang, :t) ' + . qdb_upsert_update(['stringKey', 'languageCode'], ['text' => 'excluded.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) { continue; } - $pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n) - ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name') + $pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n) ' + . qdb_upsert_update(['languageCode'], ['name' => 'excluded.name'])) ->execute([':lc' => $lc, ':n' => $name]); } diff --git a/composer.json b/composer.json index ad7f717..8952cba 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,13 @@ { "name": "nat-as/questionnaire-server", - "description": "Encrypted SQLite questionnaire API", + "description": "Questionnaire API (MySQL or legacy encrypted SQLite)", "type": "project", "require": { "php": ">=8.2", "ext-json": "*", "ext-openssl": "*", "ext-pdo": "*", + "ext-pdo_mysql": "*", "ext-pdo_sqlite": "*" }, "require-dev": { diff --git a/db_init.php b/db_init.php index 18311a8..07a0031 100644 --- a/db_init.php +++ b/db_init.php @@ -1,7 +1,7 @@ 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 { $changed = false; @@ -138,8 +120,8 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { UNIQUE(clientCode, questionnaireID, version) ) "); - $pdo->exec('CREATE INDEX IF NOT EXISTS idx_submission_client_qn ON 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_qn', 'questionnaire_submission', 'clientCode, questionnaireID'); + qdb_create_index_if_not_exists($pdo, 'idx_submission_client_time', 'questionnaire_submission', 'clientCode, submittedAt'); $changed = true; } @@ -211,7 +193,7 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { 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; } @@ -267,7 +249,7 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { 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; } @@ -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 && qdb_table_exists($pdo, 'completed_questionnaire') && 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')) { require_once __DIR__ . '/lib/scoring.php'; 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 (!qdb_column_exists($pdo, 'scoring_profile', 'greenMin')) { $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; } - $currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); + $currentVersion = qdb_schema_version($pdo); if ($currentVersion < 13 && qdb_table_exists($pdo, 'scoring_profile')) { if (!qdb_column_exists($pdo, 'scoring_profile', 'processCompleteBands')) { $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 (qdb_table_exists($pdo, 'language') && !qdb_column_exists($pdo, 'language', 'enabled')) { $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')) { $pdo->exec( 'UPDATE questionnaire_submission SET submittedAt = completedAt @@ -377,27 +359,55 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { $changed = true; } - $currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); + $currentVersion = qdb_schema_version($pdo); 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 ''"); + qdb_set_foreign_keys($pdo, false); + try { + qdb_exec_schema_sql($pdo, $schemaSql); + 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; } return $changed; } -/** - * Exclusive lock with short retries (non-blocking attempts). Avoids hung PHP workers - * when two writes overlap (app upload + website edit). - */ -function qdb_flock_exclusive_with_retry($lockFp, int $maxAttempts = 12, int $sleepMicros = 75000): void -{ +function qdb_mysql_ensure_ready(PDO $pdo): void { + static $ready = false; + if ($ready) { + return; + } + + $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++) { if (flock($lockFp, LOCK_EX | LOCK_NB)) { return; @@ -409,8 +419,7 @@ function qdb_flock_exclusive_with_retry($lockFp, int $maxAttempts = 12, int $sle throw new Exception('Could not acquire lock'); } -function qdb_open_writable_lock() -{ +function qdb_open_writable_lock() { $lockFp = fopen(QDB_LOCK, 'c'); if ($lockFp === false) { 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]. - * 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 { + if (qdb_is_mysql()) { + [$pdo] = qdb_mysql_open(); + qdb_mysql_ensure_ready($pdo); + return [$pdo, '', null]; + } + if (!$writable) { require_once __DIR__ . '/lib/read_db_cache.php'; return qdb_read_db_open(); @@ -441,29 +451,28 @@ function qdb_open(bool $writable = false): array { if (!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; - if ($writable) { - $lockFp = qdb_open_writable_lock(); - } + $lockFp = qdb_open_writable_lock(); $tmpDb = tempnam(sys_get_temp_dir(), 'qdb_'); $masterKey = get_master_key_bytes(); $sql = file_get_contents(QDB_SCHEMA); if ($sql === false) { - if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); } - throw new Exception("Could not read schema.sql"); + flock($lockFp, LOCK_UN); + fclose($lockFp); + throw new Exception('Could not read schema.sql'); } if (file_exists($dbPath) && is_file($dbPath)) { $storedEnc = @file_get_contents($dbPath); if ($storedEnc === false) { - if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); } - throw new Exception("Could not read stored DB"); + flock($lockFp, LOCK_UN); + fclose($lockFp); + throw new Exception('Could not read stored DB'); } try { $decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey); @@ -481,45 +490,32 @@ function qdb_open(bool $writable = false): array { throw $e; } if (file_put_contents($tmpDb, $decrypted) === false) { - if ($lockFp) { flock($lockFp, LOCK_UN); fclose($lockFp); } - throw new Exception("Could not write temp DB"); + flock($lockFp, LOCK_UN); + fclose($lockFp); + throw new Exception('Could not write temp DB'); } } else { $pdo = new PDO("sqlite:$tmpDb"); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->exec($sql); - $pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";"); + qdb_set_schema_version($pdo, QDB_VERSION); $pdo = null; } $pdo = new PDO("sqlite:$tmpDb"); $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); - - // 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); - } + qdb_apply_migrations($pdo, $sql); 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 { + if (qdb_is_mysql()) { + return; + } + $masterKey = get_master_key_bytes(); try { $ck = new PDO('sqlite:' . $tmpDb); @@ -528,17 +524,19 @@ function qdb_save(string $tmpDb, $lockFp): void { // best-effort before reading plaintext bytes } $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); $dbPath = QDB_PATH; $tmpEncrypted = tempnam(dirname($dbPath), 'enc_qdb_'); 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 (!@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); @@ -553,10 +551,10 @@ function qdb_save(string $tmpDb, $lockFp): void { qdb_read_cache_invalidate(); } -/** - * Discard changes -- clean up temp file and release lock without saving. - */ function qdb_discard(string $tmpDb, $lockFp): void { + if (qdb_is_mysql()) { + return; + } if (defined('QDB_READONLY_CACHE_HANDLE') && $tmpDb === QDB_READONLY_CACHE_HANDLE) { return; } @@ -567,14 +565,7 @@ function qdb_discard(string $tmpDb, $lockFp): void { } } -/** - * 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} - */ +/** @return array{0: PDO, 1: string, 2: resource|null} */ function qdb_open_read_or_fail(): array { try { 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 { try { return qdb_open(true); diff --git a/handlers/answer_options.php b/handlers/answer_options.php index 0065a9c..e0e36b5 100644 --- a/handlers/answer_options.php +++ b/handlers/answer_options.php @@ -181,10 +181,10 @@ case 'DELETE': json_success(['deleted' => false, 'retired' => true, 'structureRevision' => $newRev]); 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 WHERE answerOptionID = :id')->execute([':id' => $id]); - $pdo->exec('PRAGMA foreign_keys = ON;'); + qdb_set_foreign_keys($pdo, true); $pdo->commit(); qdb_save($tmpDb, $lockFp); json_success(['deleted' => true, 'retired' => false, 'structureRevision' => $newRev]); diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index c978a0d..d9a3a6e 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -180,15 +180,19 @@ if ($method === 'POST') { $glassByParent = []; $submittedQuestionIds = []; - $answerInsert = $pdo->prepare(" + $answerInsert = $pdo->prepare(' INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) - VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) - ON CONFLICT(clientCode, questionID) DO UPDATE SET - answerOptionID = excluded.answerOptionID, - freeTextValue = excluded.freeTextValue, - numericValue = excluded.numericValue, - answeredAt = excluded.answeredAt - "); + VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) ' + . qdb_upsert_update( + ['clientCode', 'questionID'], + [ + 'answerOptionID' => 'excluded.answerOptionID', + 'freeTextValue' => 'excluded.freeTextValue', + 'numericValue' => 'excluded.numericValue', + 'answeredAt' => 'excluded.answeredAt', + ] + ) + ); $answerDelete = $pdo->prepare( '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($pdo, $clientCode, $qnID); - $pdo->prepare(" + $pdo->prepare(' INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints) - VALUES (:cc, :qn, :abc, 'completed', :sa, :ca, :sp) - ON CONFLICT(clientCode, questionnaireID) DO UPDATE SET - assignedByCoach = excluded.assignedByCoach, - status = 'completed', - startedAt = COALESCE(excluded.startedAt, startedAt), - completedAt = excluded.completedAt, - sumPoints = excluded.sumPoints - ")->execute([ + VALUES (:cc, :qn, :abc, \'completed\', :sa, :ca, :sp) ' + . qdb_upsert_update( + ['clientCode', 'questionnaireID'], + [ + 'assignedByCoach' => 'excluded.assignedByCoach', + 'status' => "'completed'", + 'startedAt' => 'COALESCE(excluded.startedAt, startedAt)', + 'completedAt' => 'excluded.completedAt', + 'sumPoints' => 'excluded.sumPoints', + ] + ) + )->execute([ ':cc' => $clientCode, ':qn' => $qnID, ':abc' => $assignedByCoach !== '' ? $assignedByCoach : null, diff --git a/handlers/backup.php b/handlers/backup.php index a8a8e3d..efb8cad 100644 --- a/handlers/backup.php +++ b/handlers/backup.php @@ -7,21 +7,70 @@ if ($method !== 'POST') { json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); } -$dbPath = QDB_PATH; $backupDir = QDB_UPLOADS_DIR . '/backups'; - -if (!file_exists($dbPath)) { - json_error('NOT_FOUND', 'No database to back up', 404); -} - if (!is_dir($backupDir)) { if (!mkdir($backupDir, 0755, true) && !is_dir($backupDir)) { json_error('SERVER_ERROR', 'Could not create backups directory', 500); } } -$timestamp = date('Y-m-d_H-i-s'); -$filename = "questionnaire_database.$timestamp"; +$timestamp = date('Y-m-d_H-i-s'); + +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"; if (!copy($dbPath, $backupFile)) { diff --git a/handlers/questionnaires.php b/handlers/questionnaires.php index 3a172a9..78a2e6c 100644 --- a/handlers/questionnaires.php +++ b/handlers/questionnaires.php @@ -107,9 +107,9 @@ case 'POST': $replaceIfExists = !empty($body['replaceIfExists']); [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); try { - $pdo->exec('PRAGMA foreign_keys = OFF;'); + qdb_set_foreign_keys($pdo, false); $result = qdb_import_questionnaires_bundle($pdo, $bundle, $replaceIfExists); - $pdo->exec('PRAGMA foreign_keys = ON;'); + qdb_set_foreign_keys($pdo, true); qdb_save($tmpDb, $lockFp); json_success($result); } catch (Throwable $e) { @@ -208,7 +208,7 @@ case 'DELETE': [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); try { - $pdo->exec('PRAGMA foreign_keys = OFF;'); + qdb_set_foreign_keys($pdo, false); $pdo->beginTransaction(); $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->commit(); - $pdo->exec('PRAGMA foreign_keys = ON;'); + qdb_set_foreign_keys($pdo, true); qdb_save($tmpDb, $lockFp); json_success([]); } catch (Throwable $e) { diff --git a/handlers/questions.php b/handlers/questions.php index 8bebf9d..28073ea 100644 --- a/handlers/questions.php +++ b/handlers/questions.php @@ -277,7 +277,7 @@ case 'DELETE': 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->execute([':id' => $id]); 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_translation 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(); qdb_save($tmpDb, $lockFp); json_success([ diff --git a/handlers/translations.php b/handlers/translations.php index 3e06fa2..810525a 100644 --- a/handlers/translations.php +++ b/handlers/translations.php @@ -232,12 +232,12 @@ case 'PUT': if ($lang === QDB_SOURCE_LANGUAGE) { qdb_ensure_source_language($pdo); } elseif (qdb_column_exists($pdo, 'language', 'enabled')) { - $pdo->prepare("INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1) - ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name") + $pdo->prepare('INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1) ' + . qdb_upsert_update(['languageCode'], ['name' => 'excluded.name', 'enabled' => '1'])) ->execute([':lc' => $lang, ':n' => $name]); } else { - $pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n) - ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name") + $pdo->prepare('INSERT INTO language (languageCode, name) VALUES (:lc, :n) ' + . qdb_upsert_update(['languageCode'], ['name' => 'excluded.name'])) ->execute([':lc' => $lang, ':n' => $name]); } qdb_save($tmpDb, $lockFp); diff --git a/lib/analytics.php b/lib/analytics.php index 1e69341..7faa5fd 100644 --- a/lib/analytics.php +++ b/lib/analytics.php @@ -255,11 +255,15 @@ function qdb_analytics_set_followup_note(PDO $pdo, array $tokenRec, string $clie $pdo->prepare( 'INSERT INTO client_followup_note (clientCode, note, updatedByUserID, updatedAt) - VALUES (:cc, :n, :uid, :ts) - ON CONFLICT(clientCode) DO UPDATE SET - note = excluded.note, - updatedByUserID = excluded.updatedByUserID, - updatedAt = excluded.updatedAt' + VALUES (:cc, :n, :uid, :ts) ' + . qdb_upsert_update( + ['clientCode'], + [ + 'note' => 'excluded.note', + 'updatedByUserID' => 'excluded.updatedByUserID', + 'updatedAt' => 'excluded.updatedAt', + ] + ) )->execute([ ':cc' => $clientCode, ':n' => $note, diff --git a/lib/dev_fixture.php b/lib/dev_fixture.php index 7b2ac5b..a693ff7 100644 --- a/lib/dev_fixture.php +++ b/lib/dev_fixture.php @@ -575,12 +575,16 @@ function qdb_dev_persist_submission( $answeredAt = $completedAt; $answerInsert = $pdo->prepare( 'INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) - VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) - ON CONFLICT(clientCode, questionID) DO UPDATE SET - answerOptionID = excluded.answerOptionID, - freeTextValue = excluded.freeTextValue, - numericValue = excluded.numericValue, - answeredAt = excluded.answeredAt' + VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) ' + . qdb_upsert_update( + ['clientCode', 'questionID'], + [ + 'answerOptionID' => 'excluded.answerOptionID', + 'freeTextValue' => 'excluded.freeTextValue', + 'numericValue' => 'excluded.numericValue', + 'answeredAt' => 'excluded.answeredAt', + ] + ) ); foreach ($answers as $a) { @@ -645,13 +649,17 @@ function qdb_dev_persist_submission( $pdo->prepare( 'INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints) - VALUES (:cc, :qn, :abc, \'completed\', :sa, :ca, :sp) - ON CONFLICT(clientCode, questionnaireID) DO UPDATE SET - assignedByCoach = excluded.assignedByCoach, - status = \'completed\', - startedAt = COALESCE(excluded.startedAt, startedAt), - completedAt = excluded.completedAt, - sumPoints = excluded.sumPoints' + VALUES (:cc, :qn, :abc, \'completed\', :sa, :ca, :sp) ' + . qdb_upsert_update( + ['clientCode', 'questionnaireID'], + [ + 'assignedByCoach' => 'excluded.assignedByCoach', + 'status' => "'completed'", + 'startedAt' => 'COALESCE(excluded.startedAt, startedAt)', + 'completedAt' => 'excluded.completedAt', + 'sumPoints' => 'excluded.sumPoints', + ] + ) )->execute([ ':cc' => $clientCode, ':qn' => $qnID, @@ -863,7 +871,7 @@ function qdb_wipe_all_data_except_admins(PDO $pdo): array try { // Interview data references clients/coaches; coach references supervisor. // 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'); if (qdb_table_exists($pdo, 'client_answer_submission')) { @@ -891,14 +899,14 @@ function qdb_wipe_all_data_except_admins(PDO $pdo): array $users->execute(); $deleted['users'] = $users->rowCount(); - $pdo->exec('PRAGMA foreign_keys = ON'); + qdb_set_foreign_keys($pdo, true); $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } try { - $pdo->exec('PRAGMA foreign_keys = ON'); + qdb_set_foreign_keys($pdo, true); } catch (Throwable $ignored) { } throw $e; diff --git a/lib/questionnaire_structure.php b/lib/questionnaire_structure.php index a4e6e18..991f3ee 100644 --- a/lib/questionnaire_structure.php +++ b/lib/questionnaire_structure.php @@ -104,10 +104,14 @@ function qdb_save_structure_snapshot(PDO $pdo, string $qnID, int $revision, arra } $pdo->prepare( 'INSERT INTO questionnaire_structure_snapshot (questionnaireID, structureRevision, createdAt, manifestJson) - VALUES (:qn, :rev, :ts, :mj) - ON CONFLICT(questionnaireID, structureRevision) DO UPDATE SET - manifestJson = excluded.manifestJson, - createdAt = excluded.createdAt' + VALUES (:qn, :rev, :ts, :mj) ' + . qdb_upsert_update( + ['questionnaireID', 'structureRevision'], + [ + 'manifestJson' => 'excluded.manifestJson', + 'createdAt' => 'excluded.createdAt', + ] + ) )->execute([ ':qn' => $qnID, ':rev' => $revision, @@ -168,10 +172,7 @@ function qdb_option_has_client_data(PDO $pdo, string $answerOptionID): bool { if ($stmt->fetchColumn()) { return true; } - $chk = $pdo->query( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'client_answer_submission' LIMIT 1" - ); - if (!$chk || !$chk->fetchColumn()) { + if (!qdb_table_exists($pdo, 'client_answer_submission')) { return false; } $stmt = $pdo->prepare( @@ -187,10 +188,7 @@ function qdb_question_has_client_data(PDO $pdo, string $questionID): bool { if ($stmt->fetchColumn()) { return true; } - $chk = $pdo->query( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'client_answer_submission' LIMIT 1" - ); - if (!$chk || !$chk->fetchColumn()) { + if (!qdb_table_exists($pdo, 'client_answer_submission')) { return false; } $stmt = $pdo->prepare( diff --git a/lib/read_db_cache.php b/lib/read_db_cache.php index 6f58a99..0283147 100644 --- a/lib/read_db_cache.php +++ b/lib/read_db_cache.php @@ -12,6 +12,10 @@ define('QDB_READ_GENERATION_FILE', QDB_UPLOADS_DIR . '/.qdb_read_generation'); * @return array{0: PDO, 1: string, 2: null} */ function qdb_read_db_open(): array { + if (qdb_is_mysql()) { + return qdb_mysql_open(); + } + $signature = qdb_read_cache_signature(); $cached = qdb_read_cache_get($signature); if ($cached !== null) { diff --git a/lib/scoring.php b/lib/scoring.php index db58c25..a96773d 100644 --- a/lib/scoring.php +++ b/lib/scoring.php @@ -383,12 +383,16 @@ function qdb_recompute_profile_scores_for_client(PDO $pdo, string $clientCode, ? $upsert = $pdo->prepare( 'INSERT INTO client_scoring_profile_result (clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot) - VALUES (:cc, :pid, :wt, :band, :at, :snap) - ON CONFLICT(clientCode, profileID) DO UPDATE SET - weightedTotal = excluded.weightedTotal, - band = excluded.band, - computedAt = excluded.computedAt, - questionnaireSnapshot = excluded.questionnaireSnapshot' + VALUES (:cc, :pid, :wt, :band, :at, :snap) ' + . qdb_upsert_update( + ['clientCode', 'profileID'], + [ + 'weightedTotal' => 'excluded.weightedTotal', + 'band' => 'excluded.band', + 'computedAt' => 'excluded.computedAt', + 'questionnaireSnapshot' => 'excluded.questionnaireSnapshot', + ] + ) ); foreach ($profiles as $profile) { diff --git a/lib/settings.php b/lib/settings.php index 6d644d6..6dac954 100644 --- a/lib/settings.php +++ b/lib/settings.php @@ -97,8 +97,8 @@ function qdb_settings_save_on_pdo(PDO $pdo, array $settings): void qdb_ensure_system_setting_table($pdo); $stmt = $pdo->prepare( 'INSERT INTO system_setting (settingKey, settingValue) - VALUES (:k, :v) - ON CONFLICT(settingKey) DO UPDATE SET settingValue = excluded.settingValue' + VALUES (:k, :v) ' + . qdb_upsert_update(['settingKey'], ['settingValue' => 'excluded.settingValue']) ); foreach (qdb_settings_keys() as $key) { $stmt->execute([':k' => $key, ':v' => (string)(int)($settings[$key] ?? 0)]); diff --git a/lib/sql_dialect.php b/lib/sql_dialect.php new file mode 100644 index 0000000..38de566 --- /dev/null +++ b/lib/sql_dialect.php @@ -0,0 +1,226 @@ + 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 $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 $conflictColumns + * @param array $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 */ +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 !== '')); +} diff --git a/schema.mysql.sql b/schema.mysql.sql new file mode 100644 index 0000000..e0e2740 --- /dev/null +++ b/schema.mysql.sql @@ -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; diff --git a/tests/Integration/DbInitTest.php b/tests/Integration/DbInitTest.php index 965225b..fdf74dd 100644 --- a/tests/Integration/DbInitTest.php +++ b/tests/Integration/DbInitTest.php @@ -16,8 +16,8 @@ final class DbInitTest extends QdbTestCase qdb_discard($t1, $l1); [$pdo2, $t2, $l2] = qdb_open(true); - $pdo2->exec("INSERT INTO language (languageCode, name) VALUES ('fr', 'French') - ON CONFLICT(languageCode) DO NOTHING"); + $pdo2->exec("INSERT INTO language (languageCode, name) VALUES ('fr', 'French') " + . qdb_upsert_do_nothing(['languageCode'])); qdb_save($t2, $l2); [$pdo3, $t3, $l3] = qdb_open(false); @@ -35,6 +35,26 @@ final class DbInitTest extends QdbTestCase 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 { [$pdo, $tmp, $lock] = qdb_open(true); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8c10c6d..1ef0891 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -22,8 +22,9 @@ require dirname(__DIR__) . '/vendor/autoload.php'; require_once dirname(__DIR__) . '/lib/response.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). -qdb_env_set('QDB_MASTER_KEY', $testMasterKey); -$GLOBALS['qdb_dotenv']['QDB_MASTER_KEY'] = $testMasterKey; +qdb_env_override('QDB_MASTER_KEY', $testMasterKey); require_once dirname(__DIR__) . '/db_init.php'; diff --git a/tests/mysql_smoke.php b/tests/mysql_smoke.php new file mode 100644 index 0000000..1749eb0 --- /dev/null +++ b/tests/mysql_smoke.php @@ -0,0 +1,40 @@ +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");