diff --git a/src/app/duckdb.service.ts b/src/app/duckdb.service.ts index 1406724..e9f8715 100644 --- a/src/app/duckdb.service.ts +++ b/src/app/duckdb.service.ts @@ -31,11 +31,20 @@ export const Filter = z.object({ value: FilterValue.optional().array(), operator: z.enum(['and', 'or']), }); +export const aggregateTypes = ['avg', 'sum', 'min', 'max'] as const; +export const AggregateType = z.enum(aggregateTypes); + +export const Aggregate = z.object({ column: z.string(), type: AggregateType }); + +export const AggregateValue = z.object({ + column: z.string(), + value: z.number(), +}); export const RowsResponse = z.object({ - // TODO: Can't this just be a string[]? rows: z.any(), totalRows: z.bigint().nonnegative(), + aggregateValues: AggregateValue.array(), }); export type Column = z.infer; @@ -43,6 +52,9 @@ export type SortColumn = z.infer; export type FilterValue = z.infer; export type FilterOperator = z.infer; export type Filter = z.infer; +export type AggregateType = z.infer; +export type Aggregate = z.infer; +export type AggregateValue = z.infer; export type RowsResponse = z.infer; const sanitisedFileName = (file: File) => @@ -168,7 +180,7 @@ export class DuckdbService { columns: Column[], sorts: SortColumn[], filters: Filter[], - aggregations: unknown[], + aggregates: Aggregate[], ): Promise { const conn = await this.db.connect(); try { @@ -181,10 +193,20 @@ export class DuckdbService { `${prefix(value?.matchType!)}${value?.value}${suffix(value?.matchType!)}`, ), ); - const rowCountQuery = `SELECT COUNT(1) totalRows FROM ${sanitisedFileName(file)} ${whereClause}`; - const totalRowStmt = await conn.prepare(rowCountQuery); + let aggregatesQuery = 'SELECT COUNT(1) totalRows'; + if (aggregates.length > 0) { + for (const aggregate of aggregates) { + aggregatesQuery += `, ${aggregate.type}("${aggregate.column}") "${aggregate.column}"`; + } + } + aggregatesQuery += ` FROM ${sanitisedFileName(file)} ${whereClause}`; + const totalRowStmt = await conn.prepare(aggregatesQuery); const totalRowResponse = await totalRowStmt.query(...mappedFilterValues); - const { totalRows } = totalRowResponse.get(0)?.toJSON()!; + const aggregatesJson = totalRowResponse.get(0)?.toJSON()!; + const totalRows = aggregatesJson['totalRows']; + const aggregateValues: AggregateValue[] = Object.entries(aggregatesJson) + .filter(([key]) => key !== 'totalRows') + .map(([key, value]) => AggregateValue.parse({ column: key, value })); let query = `SELECT ${columns.map((column) => `"${column.name}"`).join(', ')} FROM ${sanitisedFileName(file)} ${whereClause}`; if (sorts.length > 0) { query += ` ORDER BY ${sorts.map((sort) => `"${sort.name}" ${sort.sortType}`).join(', ')}`; @@ -197,10 +219,10 @@ export class DuckdbService { rows.push(row.toJSON()!); } } - return { rows, totalRows }; + return { rows, totalRows, aggregateValues }; } catch (err) { console.error(err); - return { rows: [], totalRows: 0n }; + return { rows: [], totalRows: 0n, aggregateValues: [] }; } finally { conn.close(); } @@ -217,7 +239,7 @@ export class DuckdbService { let or = ''; for (const value of filter.value) { if (value?.value) { - query += ` ${or} ${filter.column} ${sqlOperator(value?.matchType!)} ? `; + query += ` ${or} "${filter.column}" ${sqlOperator(value?.matchType!)} ? `; or = filter.operator; } } @@ -228,4 +250,29 @@ export class DuckdbService { } return query; } + + async getAggregateValue(file: File, filters: Filter[], aggregate: Aggregate) { + const conn = await this.db.connect(); + try { + const whereClause = this.getWhereClause(filters); + const mappedFilterValues = filters.flatMap((filter) => + filter.value + .filter((value) => value?.value) + .map( + (value) => + `${prefix(value?.matchType!)}${value?.value}${suffix(value?.matchType!)}`, + ), + ); + const aggregatesQuery = `SELECT ${aggregate.type}("${aggregate.column}") "${aggregate.column}" FROM ${sanitisedFileName(file)} ${whereClause}`; + const totalRowStmt = await conn.prepare(aggregatesQuery); + const totalRowResponse = await totalRowStmt.query(...mappedFilterValues); + const aggregatesJson = totalRowResponse.get(0)?.toJSON()!; + return aggregatesJson[aggregate.column]; + } catch (err) { + console.error(err); + return { rows: [], totalRows: 0n, aggregateValues: [] }; + } finally { + conn.close(); + } + } } diff --git a/src/app/file-tree/file-tree.component.css b/src/app/file-tree/file-tree.component.css deleted file mode 100644 index 8b13789..0000000 --- a/src/app/file-tree/file-tree.component.css +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/file-tree/file-tree.component.ts b/src/app/file-tree/file-tree.component.ts index 1cfb15a..b9216ba 100644 --- a/src/app/file-tree/file-tree.component.ts +++ b/src/app/file-tree/file-tree.component.ts @@ -41,7 +41,6 @@ const SKIP_FILES = '.DS_Store'; } `, - styleUrl: './file-tree.component.css', changeDetection: ChangeDetectionStrategy.OnPush, }) export class FileTreeComponent { diff --git a/src/app/file-viewer/file-viewer.component.ts b/src/app/file-viewer/file-viewer.component.ts index e9967e4..58f6e71 100644 --- a/src/app/file-viewer/file-viewer.component.ts +++ b/src/app/file-viewer/file-viewer.component.ts @@ -5,24 +5,50 @@ import { inject, input, signal, + viewChild, } from '@angular/core'; import { Skeleton } from 'primeng/skeleton'; -import { Table, TableLazyLoadEvent, TableModule } from 'primeng/table'; -import { Column, DuckdbService, Filter, FilterValue } from '../duckdb.service'; +import { + Table, + TableColumnReorderEvent, + TableLazyLoadEvent, + TableModule, +} from 'primeng/table'; +import { + Aggregate, + AggregateType, + Column, + DuckdbService, + Filter, + FilterValue, + aggregateTypes, +} from '../duckdb.service'; import { ButtonModule } from 'primeng/button'; import { IconField } from 'primeng/iconfield'; import { InputIconModule } from 'primeng/inputicon'; +import { InputTextModule } from 'primeng/inputtext'; +import { Menu } from 'primeng/menu'; +import { MenuItem } from 'primeng/api'; +import { DecimalPipe } from '@angular/common'; @Component({ selector: 'app-file-viewer', standalone: true, - imports: [TableModule, Skeleton, ButtonModule, IconField, InputIconModule], + imports: [ + TableModule, + Skeleton, + ButtonModule, + IconField, + InputIconModule, + InputTextModule, + Menu, + DecimalPipe, + ], template: ` @if (file() && columns().length > 0) { + } @@ -88,7 +115,36 @@ import { InputIconModule } from 'primeng/inputicon'; } + + + @for (col of columns(); track $index) { + + @if (col.type === 'DOUBLE') { +
+ + + + mediation + + + + + {{ aggregateValues()[$index] | number }} + +
+ } + + } + +
+ } `, styleUrl: './file-viewer.component.css', @@ -101,9 +157,24 @@ export class FileViewerComponent { // Can't be computed since effect has to run first so file exists in duckdb protected columns = signal([]); + protected aggregates = signal<(Aggregate | undefined)[]>([]); + protected currentAggregateColumn = signal(undefined); + protected aggregateValues = signal<(number | undefined)[]>([]); protected numRows = 200; + // TODO: This needs to be limited to 500000 or something, since exceeding the max safe integer + // will cause weird layout and prevent the bottom rows/footer from being viewed. Alternately + // we can use paging instead, but allow paging to be virtual with a max page size + // of 500000 protected currentValue = signal([]); + protected appliedFilters = signal([]); + + private table = viewChild(Table); + + protected aggregateItems: MenuItem[] = aggregateTypes.map((type) => ({ + label: type, + command: () => this.updateAggregateValue(type), + })); constructor() { effect(async () => { @@ -120,15 +191,7 @@ export class FileViewerComponent { protected async onLazyLoad(event: TableLazyLoadEvent) { const file = this.file(); if (file) { - const rows = await this.duckdbService.getRows( - file, - event.first ?? 0, - event.rows ?? 0, - this.columns(), - event.multiSortMeta?.map((meta) => ({ - name: meta.field, - sortType: meta.order < 0 ? 'desc' : 'asc', - })) ?? [], + this.appliedFilters.set( event.filters ? Object.entries(event.filters).flatMap(([column, filter]) => { if (Array.isArray(filter)) { @@ -160,7 +223,18 @@ export class FileViewerComponent { return []; }) : [], - [], + ); + const rows = await this.duckdbService.getRows( + file, + event.first ?? 0, + event.rows ?? 0, + this.columns(), + event.multiSortMeta?.map((meta) => ({ + name: meta.field, + sortType: meta.order < 0 ? 'desc' : 'asc', + })) ?? [], + this.appliedFilters(), + this.aggregates().filter((agg) => !!agg) as Aggregate[], ); if (this.currentValue().length !== Number(rows.totalRows)) { this.currentValue.set(Array.from({ length: Number(rows.totalRows) })); @@ -188,6 +262,7 @@ export class FileViewerComponent { if (file) { await this.duckdbService.addFile(file); this.columns.set(await this.duckdbService.getColumns(file)); + this.aggregates.set(new Array(this.columns().length)); const rows = await this.duckdbService.getRows( file, 0, @@ -200,5 +275,33 @@ export class FileViewerComponent { const newValue = Array.from({ length: Number(rows.totalRows) }); this.currentValue.set(newValue); } + // TODO: Replace this with previous state once persistence is implemented + const table = this.table(); + if (table) { + table.multiSortMeta = [{ field: this.columns()[0].name, order: 1 }]; + table.sortMultiple(); + } + } + + private async updateAggregateValue(type: AggregateType) { + this.aggregates.update((aggregates) => { + const copy = aggregates.slice(); + copy[this.currentAggregateColumn()!] = { + type, + column: this.columns()[this.currentAggregateColumn()!].name, + }; + return copy; + }); + + const aggregateValue = await this.duckdbService.getAggregateValue( + this.file()!, + this.appliedFilters(), + this.aggregates()[this.currentAggregateColumn()!]!, + ); + this.aggregateValues.update((values) => { + const copy = values.slice(); + copy[this.currentAggregateColumn()!] = aggregateValue; + return copy; + }); } } diff --git a/src/index.html b/src/index.html index b470225..dda3cf2 100644 --- a/src/index.html +++ b/src/index.html @@ -1,4 +1,4 @@ - + @@ -6,6 +6,20 @@ + +