This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user