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

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

View File

@ -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,

View File

@ -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;

View File

@ -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(

View File

@ -12,6 +12,10 @@ define('QDB_READ_GENERATION_FILE', QDB_UPLOADS_DIR . '/.qdb_read_generation');
* @return array{0: PDO, 1: string, 2: null}
*/
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) {

View File

@ -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) {

View File

@ -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)]);

226
lib/sql_dialect.php Normal file
View File

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