Query-Builder: QueryConfig zu parametrisiertem SQL

Konvertiert strukturierte QueryConfig-Objekte in parametrisierte
SQL-Queries mit Zeitbereich-Aufloesung, Event-/Kategorie-Filtern,
Glob-to-LIKE-Pfadfiltern und GROUP BY (file/type/hour/day/session).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-12 11:02:40 +01:00
parent 3a64d723d5
commit 00446c4227

168
src/core/query-builder.ts Normal file
View file

@ -0,0 +1,168 @@
import { QueryConfig, TimeRange } from '../types';
export interface BuiltQuery {
sql: string;
params: unknown[];
}
export function buildQuery(config: QueryConfig): BuiltQuery {
const conditions: string[] = [];
const params: unknown[] = [];
// Time range
const { from, to } = resolveTimeRange(config.timeRange);
conditions.push('timestamp >= ?');
params.push(from);
conditions.push('timestamp <= ?');
params.push(to);
// Event types
if (config.eventTypes && config.eventTypes.length > 0) {
const placeholders = config.eventTypes.map(() => '?').join(', ');
conditions.push(`type IN (${placeholders})`);
params.push(...config.eventTypes);
}
// Categories
if (config.categories && config.categories.length > 0) {
const placeholders = config.categories.map(() => '?').join(', ');
conditions.push(`category IN (${placeholders})`);
params.push(...config.categories);
}
// File paths (glob patterns -> SQL LIKE)
if (config.filePaths && config.filePaths.length > 0) {
const likeConditions = config.filePaths.map(pattern => {
params.push(globToLike(pattern));
return 'source LIKE ?';
});
conditions.push(`(${likeConditions.join(' OR ')})`);
}
// Session filter
if (config.timeRange.type === 'session' && config.timeRange.sessionId) {
conditions.push('session = ?');
params.push(config.timeRange.sessionId);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// GROUP BY
if (config.groupBy) {
return buildGroupedQuery(config, where, params);
}
// Simple query
const orderCol = config.orderBy === 'count' ? 'timestamp' : (config.orderBy ?? 'timestamp');
const orderDir = config.orderDirection ?? 'desc';
const limit = config.limit ? `LIMIT ${config.limit}` : '';
const sql = `SELECT * FROM events ${where} ORDER BY ${orderCol} ${orderDir} ${limit}`;
return { sql, params };
}
function buildGroupedQuery(config: QueryConfig, where: string, params: unknown[]): BuiltQuery {
const orderDir = config.orderDirection ?? 'desc';
const limit = config.limit ? `LIMIT ${config.limit}` : '';
let groupExpr: string;
let selectExpr: string;
let orderExpr: string;
switch (config.groupBy) {
case 'file':
groupExpr = 'source';
selectExpr = `source as "group", COUNT(*) as count,
SUM(CASE WHEN json_extract(payload, '$.wordsAdded') IS NOT NULL THEN json_extract(payload, '$.wordsAdded') ELSE 0 END) as words_added,
SUM(CASE WHEN json_extract(payload, '$.wordsRemoved') IS NOT NULL THEN json_extract(payload, '$.wordsRemoved') ELSE 0 END) as words_removed`;
break;
case 'type':
groupExpr = 'type';
selectExpr = `type as "group", COUNT(*) as count`;
break;
case 'hour':
groupExpr = "strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime')";
selectExpr = `${groupExpr} as "group", COUNT(*) as count`;
break;
case 'day':
groupExpr = "strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime')";
selectExpr = `${groupExpr} as "group", COUNT(*) as count,
SUM(CASE WHEN json_extract(payload, '$.wordsAdded') IS NOT NULL THEN json_extract(payload, '$.wordsAdded') ELSE 0 END) as words_added,
SUM(CASE WHEN json_extract(payload, '$.wordsRemoved') IS NOT NULL THEN json_extract(payload, '$.wordsRemoved') ELSE 0 END) as words_removed`;
break;
case 'session':
groupExpr = 'session';
selectExpr = `session as "group", COUNT(*) as count, MIN(timestamp) as first_event, MAX(timestamp) as last_event`;
break;
default:
groupExpr = 'type';
selectExpr = `type as "group", COUNT(*) as count`;
}
switch (config.orderBy) {
case 'count':
orderExpr = `count ${orderDir}`;
break;
case 'words':
orderExpr = `words_added ${orderDir}`;
break;
default:
orderExpr = `"group" ${orderDir}`;
}
const sql = `SELECT ${selectExpr} FROM events ${where} GROUP BY ${groupExpr} ORDER BY ${orderExpr} ${limit}`;
return { sql, params };
}
// ---------------------------------------------------------------------------
// Time range resolution
// ---------------------------------------------------------------------------
function resolveTimeRange(range: TimeRange): { from: number; to: number } {
if (range.type === 'absolute') {
return { from: range.from, to: range.to };
}
if (range.type === 'session') {
return { from: 0, to: Date.now() };
}
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const msPerDay = 86400000;
switch (range.value) {
case 'today':
return { from: startOfToday, to: Date.now() };
case 'yesterday':
return { from: startOfToday - msPerDay, to: startOfToday - 1 };
case 'this-week': {
const dayOfWeek = now.getDay() || 7;
const startOfWeek = startOfToday - (dayOfWeek - 1) * msPerDay;
return { from: startOfWeek, to: Date.now() };
}
case 'this-month': {
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
return { from: startOfMonth, to: Date.now() };
}
case 'last-7-days':
return { from: startOfToday - 7 * msPerDay, to: Date.now() };
case 'last-30-days':
return { from: startOfToday - 30 * msPerDay, to: Date.now() };
default:
return { from: startOfToday, to: Date.now() };
}
}
// ---------------------------------------------------------------------------
// Glob to SQL LIKE
// ---------------------------------------------------------------------------
function globToLike(pattern: string): string {
return pattern
.replace(/%/g, '\\%')
.replace(/_/g, '\\_')
.replace(/\*\*/g, '%')
.replace(/\*/g, '%')
.replace(/\?/g, '_');
}