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