|
1 | 1 | import betterSqlite3 from 'better-sqlite3'; |
2 | | -import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, AdminForthConfig } from '../types/Back.js'; |
| 2 | +import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, AdminForthConfig, IAggregationRule, IGroupByRule, IGroupByDateTrunc, IGroupByField } from '../types/Back.js'; |
3 | 3 | import AdminForthBaseConnector from './baseConnector.js'; |
4 | 4 | import dayjs from 'dayjs'; |
5 | 5 | import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections } from '../types/Common.js'; |
@@ -299,6 +299,123 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData |
299 | 299 | return filter.subFilters.length ? `WHERE ${this.getFilterString(filter)}` : ''; |
300 | 300 | } |
301 | 301 |
|
| 302 | + private _dateGroupKey(rawValue: any, underlineType: string, truncation: string, timezone: string): string { |
| 303 | + const date = (underlineType === 'timestamp' || underlineType === 'int') |
| 304 | + ? new Date(Number(rawValue) * 1000) |
| 305 | + : new Date(rawValue); |
| 306 | + |
| 307 | + const fmt = (opts: Intl.DateTimeFormatOptions) => |
| 308 | + new Intl.DateTimeFormat('en', { timeZone: timezone, ...opts }).formatToParts(date); |
| 309 | + |
| 310 | + const get = (parts: Intl.DateTimeFormatPart[], type: string) => |
| 311 | + parts.find(p => p.type === type)?.value ?? ''; |
| 312 | + |
| 313 | + const dateParts = fmt({ year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short' }); |
| 314 | + const year = get(dateParts, 'year'); |
| 315 | + const month = get(dateParts, 'month'); |
| 316 | + const day = get(dateParts, 'day'); |
| 317 | + const dateStr = `${year}-${month}-${day}`; |
| 318 | + |
| 319 | + switch (truncation) { |
| 320 | + case 'day': return dateStr; |
| 321 | + case 'week': { |
| 322 | + const dowMap: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; |
| 323 | + const dow = dowMap[get(dateParts, 'weekday')] ?? 0; |
| 324 | + const daysBack = dow === 0 ? 6 : dow - 1; // rewind to Monday (ISO) |
| 325 | + const [y, m, d] = dateStr.split('-').map(Number); |
| 326 | + return new Date(Date.UTC(y, m - 1, d - daysBack)).toISOString().split('T')[0]; |
| 327 | + } |
| 328 | + case 'month': return `${year}-${month}-01`; |
| 329 | + case 'year': return `${year}-01-01`; |
| 330 | + default: return dateStr; |
| 331 | + } |
| 332 | + } |
| 333 | + |
| 334 | + async getAggregateWithOriginalTypes({ resource, filters, aggregations, groupBy }: { |
| 335 | + resource: AdminForthResource, |
| 336 | + filters: IAdminForthAndOrFilter, |
| 337 | + aggregations: { [alias: string]: IAggregationRule }, |
| 338 | + groupBy?: IGroupByRule, |
| 339 | + }): Promise<Array<{ group?: string, [key: string]: any }>> { |
| 340 | + const tableName = resource.table; |
| 341 | + const where = this.whereClause(filters); |
| 342 | + const filterValues = this.getFilterParams(filters); |
| 343 | + |
| 344 | + if (!groupBy || groupBy.type === 'field') { |
| 345 | + const selectParts: string[] = []; |
| 346 | + let groupExpr: string | null = null; |
| 347 | + |
| 348 | + if (groupBy?.type === 'field') { |
| 349 | + const g = groupBy as IGroupByField; |
| 350 | + groupExpr = `"${g.field}"`; |
| 351 | + selectParts.push(`${groupExpr} AS "group"`); |
| 352 | + } |
| 353 | + |
| 354 | + for (const [alias, rule] of Object.entries(aggregations)) { |
| 355 | + switch (rule.operation) { |
| 356 | + case 'sum': selectParts.push(`SUM("${rule.field}") AS "${alias}"`); break; |
| 357 | + case 'count': selectParts.push(`COUNT(*) AS "${alias}"`); break; |
| 358 | + case 'avg': selectParts.push(`AVG("${rule.field}") AS "${alias}"`); break; |
| 359 | + case 'min': selectParts.push(`MIN("${rule.field}") AS "${alias}"`); break; |
| 360 | + case 'max': selectParts.push(`MAX("${rule.field}") AS "${alias}"`); break; |
| 361 | + case 'median': throw new Error('Aggregates.median() with GroupBy.Field is not supported in SQLite.'); |
| 362 | + } |
| 363 | + } |
| 364 | + |
| 365 | + let query = `SELECT ${selectParts.join(', ')} FROM ${tableName} ${where}`; |
| 366 | + if (groupExpr) query += ` GROUP BY ${groupExpr} ORDER BY ${groupExpr} ASC`; |
| 367 | + dbLogger.trace(`🪲📜 SQLITE AGG Q: ${query}, params: ${JSON.stringify(filterValues)}`); |
| 368 | + return this.client.prepare(query).all([...filterValues]); |
| 369 | + } |
| 370 | + |
| 371 | + const g = groupBy as IGroupByDateTrunc; |
| 372 | + const timezone = g.timezone ?? 'UTC'; |
| 373 | + const col = resource.dataSourceColumns.find(c => c.name === g.field); |
| 374 | + const underlineType = col?._underlineType ?? 'varchar'; |
| 375 | + |
| 376 | + const neededFields = new Set<string>([g.field]); |
| 377 | + for (const rule of Object.values(aggregations)) { |
| 378 | + if (rule.field) neededFields.add(rule.field); |
| 379 | + } |
| 380 | + const selectCols = [...neededFields].map(f => `"${f}"`).join(', '); |
| 381 | + const rawQuery = `SELECT ${selectCols} FROM ${tableName} ${where}`; |
| 382 | + dbLogger.trace(`🪲📜 SQLITE AGG RAW Q: ${rawQuery}, params: ${JSON.stringify(filterValues)}`); |
| 383 | + const rawRows: any[] = this.client.prepare(rawQuery).all([...filterValues]); |
| 384 | + |
| 385 | + const groups = new Map<string, any[]>(); |
| 386 | + for (const row of rawRows) { |
| 387 | + const key = this._dateGroupKey(row[g.field], underlineType, g.truncation, timezone); |
| 388 | + if (!groups.has(key)) groups.set(key, []); |
| 389 | + groups.get(key)!.push(row); |
| 390 | + } |
| 391 | + |
| 392 | + const results: Array<{ group: string, [key: string]: any }> = []; |
| 393 | + for (const [groupKey, rows] of groups) { |
| 394 | + const result: { group: string, [key: string]: any } = { group: groupKey }; |
| 395 | + for (const [alias, rule] of Object.entries(aggregations)) { |
| 396 | + const nums = rule.field ? rows.map(r => Number(r[rule.field!] ?? 0)) : []; |
| 397 | + switch (rule.operation) { |
| 398 | + case 'count': result[alias] = rows.length; break; |
| 399 | + case 'sum': result[alias] = nums.reduce((s, v) => s + v, 0); break; |
| 400 | + case 'avg': result[alias] = nums.reduce((s, x) => s + x, 0) / nums.length; break; |
| 401 | + case 'min': result[alias] = Math.min(...nums); break; |
| 402 | + case 'max': result[alias] = Math.max(...nums); break; |
| 403 | + case 'median': { |
| 404 | + const sorted = nums.slice().sort((a, b) => a - b); |
| 405 | + const mid = Math.floor(sorted.length / 2); |
| 406 | + result[alias] = sorted.length % 2 === 0 |
| 407 | + ? (sorted[mid - 1] + sorted[mid]) / 2 |
| 408 | + : sorted[mid]; |
| 409 | + break; |
| 410 | + } |
| 411 | + } |
| 412 | + } |
| 413 | + results.push(result); |
| 414 | + } |
| 415 | + |
| 416 | + return results.sort((a, b) => a.group.localeCompare(b.group)); |
| 417 | + } |
| 418 | + |
302 | 419 | async getDataWithOriginalTypes({ resource, limit, offset, sort, filters }): Promise<any[]> { |
303 | 420 | const columns = resource.dataSourceColumns.map((col) => col.name).join(', '); |
304 | 421 | const tableName = resource.table; |
|
0 commit comments