This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
-- Ergänzt den manuellen Freigabezeitpunkt für Strecken.
|
-- Ergänzt den manuellen Freigabezeitpunkt für Strecken.
|
||||||
-- Neue Strecken bleiben unveröffentlicht, solange activated_at is NULL.
|
-- Neue Strecken bleiben unveröffentlicht, solange activated_at is NULL.
|
||||||
ALTER TABLE routes ADD COLUMN activated_at TEXT DEFAULT NULL;
|
|
||||||
|
ALTER TABLE routes ADD COLUMN activated_at TEXT DEFAULT NULL;
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS routes (
|
|||||||
CREATE TRIGGER IF NOT EXISTS routes_updated_at
|
CREATE TRIGGER IF NOT EXISTS routes_updated_at
|
||||||
AFTER UPDATE ON routes
|
AFTER UPDATE ON routes
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
WHEN NEW.updated_at = OLD.updated_at
|
WHEN NEW.updated_at IS OLD.updated_at
|
||||||
BEGIN
|
BEGIN
|
||||||
UPDATE routes
|
UPDATE routes
|
||||||
SET updated_at = CURRENT_TIMESTAMP
|
SET updated_at = CURRENT_TIMESTAMP
|
||||||
|
|||||||
@@ -36,3 +36,11 @@ npm run test:coverage
|
|||||||
## SonarQube
|
## SonarQube
|
||||||
|
|
||||||
Der frühere HTML-Smell durch `role="status"` am Editorstatus wurde entfernt. Der Status ist jetzt ein semantisches `<output>` mit `aria-live="polite"`.
|
Der frühere HTML-Smell durch `role="status"` am Editorstatus wurde entfernt. Der Status ist jetzt ein semantisches `<output>` 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.
|
||||||
|
|||||||
@@ -3,66 +3,36 @@ import path from 'node:path';
|
|||||||
|
|
||||||
const root = path.resolve(import.meta.dirname, '..');
|
const root = path.resolve(import.meta.dirname, '..');
|
||||||
const schemaPath = path.join(root, 'database/schema.sql');
|
const schemaPath = path.join(root, 'database/schema.sql');
|
||||||
const columnSql = ' activated_at DATETIME NULL';
|
|
||||||
|
|
||||||
function hasActivatedAt(schema) {
|
function tokens(line) {
|
||||||
return /\bactivated_at\s+DATETIME\s+NULL\b/i.test(schema);
|
return line
|
||||||
|
.trim()
|
||||||
|
.replaceAll(',', ' ')
|
||||||
|
.replaceAll(';', ' ')
|
||||||
|
.replaceAll('(', ' ')
|
||||||
|
.replaceAll(')', ' ')
|
||||||
|
.split(' ')
|
||||||
|
.map(token => token.trim())
|
||||||
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitRouteTableBody(body) {
|
function hasActivationColumn(schema) {
|
||||||
const lines = body.split('\n');
|
return schema
|
||||||
const trailing = [];
|
.split('\n')
|
||||||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
.map(tokens)
|
||||||
trailing.unshift(lines.pop());
|
.some(parts => {
|
||||||
}
|
const upper = parts.map(part => part.toUpperCase());
|
||||||
return { lines, trailing };
|
return upper[0] === 'ACTIVATED_AT'
|
||||||
}
|
&& upper[1] === 'TEXT'
|
||||||
|
&& upper.includes('DEFAULT')
|
||||||
function isTableConstraint(line) {
|
&& upper.includes('NULL');
|
||||||
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*\((?<body>[\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')));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = await fs.readFile(schemaPath, 'utf8');
|
const schema = await fs.readFile(schemaPath, 'utf8');
|
||||||
const next = insertActivatedAt(schema);
|
|
||||||
if (next === schema) {
|
if (!hasActivationColumn(schema)) {
|
||||||
console.log('database/schema.sql enthält activated_at bereits.');
|
throw new Error('database/schema.sql muss routes.activated_at als TEXT DEFAULT NULL enthalten.');
|
||||||
} else {
|
|
||||||
await fs.writeFile(schemaPath, next);
|
|
||||||
console.log('database/schema.sql wurde um routes.activated_at ergänzt.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('database/schema.sql enthält routes.activated_at als SQLite-STRICT-kompatible TEXT-Spalte.');
|
||||||
|
|||||||
@@ -9,16 +9,55 @@ async function read(relativePath) {
|
|||||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
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 () => {
|
test('initial database schema creates routes with a nullable activation timestamp', async () => {
|
||||||
const schema = await read('database/schema.sql');
|
const schema = await read('database/schema.sql');
|
||||||
const migration = await read('database/migrations/20260901_add_route_activated_at.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.equal(hasActivatedAtTextDefaultNull(routes), true);
|
||||||
assert.match(routeTable[0], /\bactivated_at\s+TEXT\s+(?:NULL\s+)?DEFAULT\s+NULL\b/i);
|
assert.equal(containsTokenSequence(routes.join('\n'), ['ACTIVATED_AT', 'DATETIME']), false);
|
||||||
assert.doesNotMatch(routeTable[0], /\bactivated_at\s+DATETIME\b/i);
|
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.match(migration, /ALTER\s+TABLE\s+routes\s+ADD\s+COLUMN\s+activated_at\s+TEXT\s+(?:NULL\s+)?DEFAULT\s+NULL/i);
|
assert.equal(containsTokenSequence(migration, ['ACTIVATED_AT', 'DATETIME']), false);
|
||||||
assert.doesNotMatch(migration, /\bactivated_at\s+DATETIME\b/i);
|
assert.equal(migration.includes('activated_at is NULL'), true);
|
||||||
assert.match(migration, /Neue Strecken bleiben unveröffentlicht|unpublished|activated_at is NULL/i);
|
});
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user