Add basic aggregate selection
All checks were successful
build / build (push) Successful in 1m36s

This commit is contained in:
2025-05-21 20:11:59 +09:30
parent ece4a33e1c
commit 1fd7ce6d10
5 changed files with 187 additions and 25 deletions

View File

@@ -31,11 +31,20 @@ export const Filter = z.object({
value: FilterValue.optional().array(), value: FilterValue.optional().array(),
operator: z.enum(['and', 'or']), 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({ export const RowsResponse = z.object({
// TODO: Can't this just be a string[]?
rows: z.any(), rows: z.any(),
totalRows: z.bigint().nonnegative(), totalRows: z.bigint().nonnegative(),
aggregateValues: AggregateValue.array(),
}); });
export type Column = z.infer<typeof Column>; export type Column = z.infer<typeof Column>;
@@ -43,6 +52,9 @@ export type SortColumn = z.infer<typeof SortColumn>;
export type FilterValue = z.infer<typeof FilterValue>; export type FilterValue = z.infer<typeof FilterValue>;
export type FilterOperator = z.infer<typeof FilterOperator>; export type FilterOperator = z.infer<typeof FilterOperator>;
export type Filter = z.infer<typeof Filter>; export type Filter = z.infer<typeof Filter>;
export type AggregateType = z.infer<typeof AggregateType>;
export type Aggregate = z.infer<typeof Aggregate>;
export type AggregateValue = z.infer<typeof AggregateValue>;
export type RowsResponse = z.infer<typeof RowsResponse>; export type RowsResponse = z.infer<typeof RowsResponse>;
const sanitisedFileName = (file: File) => const sanitisedFileName = (file: File) =>
@@ -168,7 +180,7 @@ export class DuckdbService {
columns: Column[], columns: Column[],
sorts: SortColumn[], sorts: SortColumn[],
filters: Filter[], filters: Filter[],
aggregations: unknown[], aggregates: Aggregate[],
): Promise<RowsResponse> { ): Promise<RowsResponse> {
const conn = await this.db.connect(); const conn = await this.db.connect();
try { try {
@@ -181,10 +193,20 @@ export class DuckdbService {
`${prefix(value?.matchType!)}${value?.value}${suffix(value?.matchType!)}`, `${prefix(value?.matchType!)}${value?.value}${suffix(value?.matchType!)}`,
), ),
); );
const rowCountQuery = `SELECT COUNT(1) totalRows FROM ${sanitisedFileName(file)} ${whereClause}`; let aggregatesQuery = 'SELECT COUNT(1) totalRows';
const totalRowStmt = await conn.prepare(rowCountQuery); 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 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}`; let query = `SELECT ${columns.map((column) => `"${column.name}"`).join(', ')} FROM ${sanitisedFileName(file)} ${whereClause}`;
if (sorts.length > 0) { if (sorts.length > 0) {
query += ` ORDER BY ${sorts.map((sort) => `"${sort.name}" ${sort.sortType}`).join(', ')}`; query += ` ORDER BY ${sorts.map((sort) => `"${sort.name}" ${sort.sortType}`).join(', ')}`;
@@ -197,10 +219,10 @@ export class DuckdbService {
rows.push(row.toJSON()!); rows.push(row.toJSON()!);
} }
} }
return { rows, totalRows }; return { rows, totalRows, aggregateValues };
} catch (err) { } catch (err) {
console.error(err); console.error(err);
return { rows: [], totalRows: 0n }; return { rows: [], totalRows: 0n, aggregateValues: [] };
} finally { } finally {
conn.close(); conn.close();
} }
@@ -217,7 +239,7 @@ export class DuckdbService {
let or = ''; let or = '';
for (const value of filter.value) { for (const value of filter.value) {
if (value?.value) { if (value?.value) {
query += ` ${or} ${filter.column} ${sqlOperator(value?.matchType!)} ? `; query += ` ${or} "${filter.column}" ${sqlOperator(value?.matchType!)} ? `;
or = filter.operator; or = filter.operator;
} }
} }
@@ -228,4 +250,29 @@ export class DuckdbService {
} }
return query; 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();
}
}
} }

View File

@@ -1 +0,0 @@

View File

@@ -41,7 +41,6 @@ const SKIP_FILES = '.DS_Store';
</div> </div>
} }
`, `,
styleUrl: './file-tree.component.css',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class FileTreeComponent { export class FileTreeComponent {

View File

@@ -5,24 +5,50 @@ import {
inject, inject,
input, input,
signal, signal,
viewChild,
} from '@angular/core'; } from '@angular/core';
import { Skeleton } from 'primeng/skeleton'; import { Skeleton } from 'primeng/skeleton';
import { Table, TableLazyLoadEvent, TableModule } from 'primeng/table'; import {
import { Column, DuckdbService, Filter, FilterValue } from '../duckdb.service'; Table,
TableColumnReorderEvent,
TableLazyLoadEvent,
TableModule,
} from 'primeng/table';
import {
Aggregate,
AggregateType,
Column,
DuckdbService,
Filter,
FilterValue,
aggregateTypes,
} from '../duckdb.service';
import { ButtonModule } from 'primeng/button'; import { ButtonModule } from 'primeng/button';
import { IconField } from 'primeng/iconfield'; import { IconField } from 'primeng/iconfield';
import { InputIconModule } from 'primeng/inputicon'; 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({ @Component({
selector: 'app-file-viewer', selector: 'app-file-viewer',
standalone: true, standalone: true,
imports: [TableModule, Skeleton, ButtonModule, IconField, InputIconModule], imports: [
TableModule,
Skeleton,
ButtonModule,
IconField,
InputIconModule,
InputTextModule,
Menu,
DecimalPipe,
],
template: ` template: `
@if (file() && columns().length > 0) { @if (file() && columns().length > 0) {
<p-table <p-table
#table #table
sortMode="multiple" sortMode="multiple"
[reorderableColumns]="true"
[resizableColumns]="true" [resizableColumns]="true"
columnResizeMode="expand" columnResizeMode="expand"
[columns]="columns()" [columns]="columns()"
@@ -66,6 +92,7 @@ import { InputIconModule } from 'primeng/inputicon';
{{ col.name }} {{ col.name }}
<p-columnFilter type="text" [field]="col.name" display="menu" /> <p-columnFilter type="text" [field]="col.name" display="menu" />
<p-sortIcon [field]="col.name" /> <p-sortIcon [field]="col.name" />
<!-- More options can go here... integrate it with the column filter? -->
</th> </th>
} }
</tr> </tr>
@@ -88,7 +115,36 @@ import { InputIconModule } from 'primeng/inputicon';
} }
</tr> </tr>
</ng-template> </ng-template>
<ng-template #footer>
<tr>
@for (col of columns(); track $index) {
<td #attachment>
@if (col.type === 'DOUBLE') {
<div class="flex items-baseline">
<p-button
(click)="
currentAggregateColumn.set($index);
aggregateMenu.toggle($event)
"
[label]="aggregates()[$index]?.type?.toUpperCase()"
>
<ng-template #icon>
<span class="material-symbols-outlined">
mediation
</span>
</ng-template>
</p-button>
<span class="flex-1 text-end">
{{ aggregateValues()[$index] | number }}
</span>
</div>
}
</td>
}
</tr>
</ng-template>
</p-table> </p-table>
<p-menu #aggregateMenu [model]="aggregateItems" [popup]="true" />
} }
`, `,
styleUrl: './file-viewer.component.css', 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 // Can't be computed since effect has to run first so file exists in duckdb
protected columns = signal<Column[]>([]); protected columns = signal<Column[]>([]);
protected aggregates = signal<(Aggregate | undefined)[]>([]);
protected currentAggregateColumn = signal<number | undefined>(undefined);
protected aggregateValues = signal<(number | undefined)[]>([]);
protected numRows = 200; 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<any[]>([]); protected currentValue = signal<any[]>([]);
protected appliedFilters = signal<Filter[]>([]);
private table = viewChild(Table);
protected aggregateItems: MenuItem[] = aggregateTypes.map((type) => ({
label: type,
command: () => this.updateAggregateValue(type),
}));
constructor() { constructor() {
effect(async () => { effect(async () => {
@@ -120,15 +191,7 @@ export class FileViewerComponent {
protected async onLazyLoad(event: TableLazyLoadEvent) { protected async onLazyLoad(event: TableLazyLoadEvent) {
const file = this.file(); const file = this.file();
if (file) { if (file) {
const rows = await this.duckdbService.getRows( this.appliedFilters.set(
file,
event.first ?? 0,
event.rows ?? 0,
this.columns(),
event.multiSortMeta?.map((meta) => ({
name: meta.field,
sortType: meta.order < 0 ? 'desc' : 'asc',
})) ?? [],
event.filters event.filters
? Object.entries(event.filters).flatMap(([column, filter]) => { ? Object.entries(event.filters).flatMap(([column, filter]) => {
if (Array.isArray(filter)) { if (Array.isArray(filter)) {
@@ -160,7 +223,18 @@ export class FileViewerComponent {
return []; 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)) { if (this.currentValue().length !== Number(rows.totalRows)) {
this.currentValue.set(Array.from({ length: Number(rows.totalRows) })); this.currentValue.set(Array.from({ length: Number(rows.totalRows) }));
@@ -188,6 +262,7 @@ export class FileViewerComponent {
if (file) { if (file) {
await this.duckdbService.addFile(file); await this.duckdbService.addFile(file);
this.columns.set(await this.duckdbService.getColumns(file)); this.columns.set(await this.duckdbService.getColumns(file));
this.aggregates.set(new Array(this.columns().length));
const rows = await this.duckdbService.getRows( const rows = await this.duckdbService.getRows(
file, file,
0, 0,
@@ -200,5 +275,33 @@ export class FileViewerComponent {
const newValue = Array.from({ length: Number(rows.totalRows) }); const newValue = Array.from({ length: Number(rows.totalRows) });
this.currentValue.set(newValue); 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;
});
} }
} }

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@@ -6,6 +6,20 @@
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/x-icon" href="favicon.ico" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=mediation"
/>
<style>
.material-symbols-outlined {
font-variation-settings:
"FILL" 0,
"wght" 400,
"GRAD" 0,
"opsz" 20;
font-size: 1rem;
}
</style>
</head> </head>
<body class="h-screen"> <body class="h-screen">
<app-root></app-root> <app-root></app-root>