diff --git a/src/core/query-builder.ts b/src/core/query-builder.ts new file mode 100644 index 0000000..94e6f3b --- /dev/null +++ b/src/core/query-builder.ts @@ -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, '_'); +}