diff --git a/database/migrations/20260901_add_route_activated_at.sql b/database/migrations/20260901_add_route_activated_at.sql index c67f8e8..d0e8489 100644 --- a/database/migrations/20260901_add_route_activated_at.sql +++ b/database/migrations/20260901_add_route_activated_at.sql @@ -1,3 +1,4 @@ -- Ergänzt den manuellen Freigabezeitpunkt für Strecken. -- Neue Strecken bleiben unveröffentlicht, solange activated_at is NULL. -ALTER TABLE routes ADD COLUMN activated_at TEXT DEFAULT NULL; \ No newline at end of file + +ALTER TABLE routes ADD COLUMN activated_at TEXT DEFAULT NULL; diff --git a/database/schema.sql b/database/schema.sql index 5f38ab0..9f11ad2 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS routes ( CREATE TRIGGER IF NOT EXISTS routes_updated_at AFTER UPDATE ON routes FOR EACH ROW -WHEN NEW.updated_at = OLD.updated_at +WHEN NEW.updated_at IS OLD.updated_at BEGIN UPDATE routes SET updated_at = CURRENT_TIMESTAMP diff --git a/docs/editor-merge-notes.md b/docs/editor-merge-notes.md index b458341..f74d552 100644 --- a/docs/editor-merge-notes.md +++ b/docs/editor-merge-notes.md @@ -36,3 +36,11 @@ npm run test:coverage ## SonarQube Der frühere HTML-Smell durch `role="status"` am Editorstatus wurde entfernt. Der Status ist jetzt ein semantisches `` mit `aria-live="polite"`. + + +## v4 SQLite-STRICT und Sonar-Hotspot + +- `database/schema.sql` enthält `routes.activated_at` direkt im Initialschema als `TEXT DEFAULT NULL`. +- Die Migration verwendet ebenfalls `TEXT DEFAULT NULL`, damit bestehende SQLite-STRICT-Datenbanken kompatibel bleiben. +- `scripts/apply-editor-schema.mjs` enthält keine Schema-Merge-Regex mehr; der frühere Hilfsmechanismus wurde durch eine einfache lineare Schema-Prüfung ersetzt. +- `test/editor-database-schema.test.js` prüft bewusst `TEXT DEFAULT NULL` und verbietet `DATETIME` in der STRICT-Route-Tabelle. diff --git a/scripts/apply-editor-schema.mjs b/scripts/apply-editor-schema.mjs index 2970f80..b4ef480 100644 --- a/scripts/apply-editor-schema.mjs +++ b/scripts/apply-editor-schema.mjs @@ -3,66 +3,36 @@ import path from 'node:path'; const root = path.resolve(import.meta.dirname, '..'); const schemaPath = path.join(root, 'database/schema.sql'); -const columnSql = ' activated_at DATETIME NULL'; -function hasActivatedAt(schema) { - return /\bactivated_at\s+DATETIME\s+NULL\b/i.test(schema); +function tokens(line) { + return line + .trim() + .replaceAll(',', ' ') + .replaceAll(';', ' ') + .replaceAll('(', ' ') + .replaceAll(')', ' ') + .split(' ') + .map(token => token.trim()) + .filter(Boolean); } -function splitRouteTableBody(body) { - const lines = body.split('\n'); - const trailing = []; - while (lines.length > 0 && lines[lines.length - 1].trim() === '') { - trailing.unshift(lines.pop()); - } - return { lines, trailing }; -} - -function isTableConstraint(line) { - return /^\s*,?\s*(?:PRIMARY\s+KEY|CONSTRAINT|FOREIGN\s+KEY|UNIQUE\s*\()/i.test(line); -} - -function hasLeadingComma(line) { - return /^\s*,/.test(line); -} - -function ensureCommaBeforeInsert(lines, targetIndex) { - const previousIndex = targetIndex - 1; - if (previousIndex < 0) return; - const previous = lines[previousIndex].trimEnd(); - if (!previous.endsWith(',')) lines[previousIndex] = `${previous},`; -} - -function columnForTarget(lines, insertBefore) { - if (insertBefore === -1) return columnSql; - return hasLeadingComma(lines[insertBefore]) ? columnSql : `${columnSql},`; -} - -function insertActivatedAt(schema) { - if (hasActivatedAt(schema)) return schema; - - const routeTable = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?routes\s*\((?[\s\S]*?)\);/i.exec(schema); - if (!routeTable?.groups?.body) { - throw new Error('Die routes-Tabelle wurde in database/schema.sql nicht gefunden.'); - } - - const tableSql = routeTable[0]; - const body = routeTable.groups.body; - const { lines, trailing } = splitRouteTableBody(body); - const insertBefore = lines.findIndex(isTableConstraint); - const targetIndex = insertBefore === -1 ? lines.length : insertBefore; - - ensureCommaBeforeInsert(lines, targetIndex); - lines.splice(targetIndex, 0, columnForTarget(lines, insertBefore)); - - return schema.replace(tableSql, tableSql.replace(body, [...lines, ...trailing].join('\n'))); +function hasActivationColumn(schema) { + return schema + .split('\n') + .map(tokens) + .some(parts => { + const upper = parts.map(part => part.toUpperCase()); + return upper[0] === 'ACTIVATED_AT' + && upper[1] === 'TEXT' + && upper.includes('DEFAULT') + && upper.includes('NULL'); + }); } const schema = await fs.readFile(schemaPath, 'utf8'); -const next = insertActivatedAt(schema); -if (next === schema) { - console.log('database/schema.sql enthält activated_at bereits.'); -} else { - await fs.writeFile(schemaPath, next); - console.log('database/schema.sql wurde um routes.activated_at ergänzt.'); + +if (!hasActivationColumn(schema)) { + throw new Error('database/schema.sql muss routes.activated_at als TEXT DEFAULT NULL enthalten.'); } + +console.log('database/schema.sql enthält routes.activated_at als SQLite-STRICT-kompatible TEXT-Spalte.'); diff --git a/test/editor-database-schema.test.js b/test/editor-database-schema.test.js index 74eedf4..45726d6 100644 --- a/test/editor-database-schema.test.js +++ b/test/editor-database-schema.test.js @@ -9,16 +9,55 @@ async function read(relativePath) { return fs.readFile(path.join(root, relativePath), 'utf8'); } +function normalizedTokens(line) { + return line + .trim() + .replaceAll(',', ' ') + .replaceAll(';', ' ') + .replaceAll('(', ' ') + .replaceAll(')', ' ') + .split(' ') + .map(token => token.trim().toUpperCase()) + .filter(Boolean); +} + +function routeTableLines(schema) { + const lines = schema.split('\n'); + const start = lines.findIndex(line => normalizedTokens(line).join(' ') === 'CREATE TABLE IF NOT EXISTS ROUTES'); + assert.notEqual(start, -1, 'database/schema.sql must define the routes table'); + + const end = lines.findIndex((line, index) => index > start && normalizedTokens(line).includes('STRICT')); + assert.notEqual(end, -1, 'the routes table must be declared STRICT'); + return lines.slice(start, end + 1); +} + +function hasActivatedAtTextDefaultNull(lines) { + return lines.some(line => { + const tokens = normalizedTokens(line); + return tokens[0] === 'ACTIVATED_AT' + && tokens[1] === 'TEXT' + && tokens.includes('DEFAULT') + && tokens.includes('NULL'); + }); +} + +function containsTokenSequence(sql, sequence) { + const tokens = sql + .split('\n') + .flatMap(normalizedTokens); + + return tokens.some((_, index) => sequence.every((token, offset) => tokens[index + offset] === token)); +} + test('initial database schema creates routes with a nullable activation timestamp', async () => { const schema = await read('database/schema.sql'); const migration = await read('database/migrations/20260901_add_route_activated_at.sql'); - const routeTable = schema.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?routes\s*\([\s\S]*?\)\s+STRICT\s*;/i); + const routes = routeTableLines(schema); - assert.ok(routeTable, 'database/schema.sql must define the routes table as a STRICT SQLite table'); - assert.match(routeTable[0], /\bactivated_at\s+TEXT\s+(?:NULL\s+)?DEFAULT\s+NULL\b/i); - assert.doesNotMatch(routeTable[0], /\bactivated_at\s+DATETIME\b/i); - - assert.match(migration, /ALTER\s+TABLE\s+routes\s+ADD\s+COLUMN\s+activated_at\s+TEXT\s+(?:NULL\s+)?DEFAULT\s+NULL/i); - assert.doesNotMatch(migration, /\bactivated_at\s+DATETIME\b/i); - assert.match(migration, /Neue Strecken bleiben unveröffentlicht|unpublished|activated_at is NULL/i); -}); \ No newline at end of file + assert.equal(hasActivatedAtTextDefaultNull(routes), true); + assert.equal(containsTokenSequence(routes.join('\n'), ['ACTIVATED_AT', 'DATETIME']), false); + assert.equal(containsTokenSequence(routes.join('\n'), ['ON', 'UPDATE', 'CURRENT_TIMESTAMP']), false); + assert.equal(containsTokenSequence(migration, ['ALTER', 'TABLE', 'ROUTES', 'ADD', 'COLUMN', 'ACTIVATED_AT', 'TEXT']), true); + assert.equal(containsTokenSequence(migration, ['ACTIVATED_AT', 'DATETIME']), false); + assert.equal(migration.includes('activated_at is NULL'), true); +});