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:
parent
3a64d723d5
commit
00446c4227
1 changed files with 168 additions and 0 deletions
168
src/core/query-builder.ts
Normal file
168
src/core/query-builder.ts
Normal 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, '_');
|
||||
}
|
||||
Loading…
Reference in a new issue