Add basic filtering
All checks were successful
build / build (push) Successful in 1m37s

This commit is contained in:
2025-05-02 19:46:40 +09:30
parent f388efe667
commit 95855b9e99
3 changed files with 183 additions and 49 deletions

View File

@@ -7,8 +7,6 @@ import { IconField } from 'primeng/iconfield';
import { InputIcon } from 'primeng/inputicon'; import { InputIcon } from 'primeng/inputicon';
import { SplitButtonModule } from 'primeng/splitbutton'; import { SplitButtonModule } from 'primeng/splitbutton';
import { InputTextModule } from 'primeng/inputtext'; import { InputTextModule } from 'primeng/inputtext';
import { TableLazyLoadEvent, TableModule } from 'primeng/table';
import { Skeleton } from 'primeng/skeleton';
import { FileTreeComponent } from './file-tree/file-tree.component'; import { FileTreeComponent } from './file-tree/file-tree.component';
import { FileViewerComponent } from './file-viewer/file-viewer.component'; import { FileViewerComponent } from './file-viewer/file-viewer.component';

View File

@@ -7,37 +7,82 @@ export const Column = z.object({
type: z.string(), type: z.string(),
}); });
const SortColumn = z.object({ export const SortColumn = z.object({
name: z.string(), name: z.string(),
sortType: z.enum(['asc', 'desc']), sortType: z.enum(['asc', 'desc']),
}); });
const Filter = z.object({ export const FilterOperator = z.enum([
value: z.string().array(), 'startsWith',
operator: z.enum(['and', 'or']), 'contains',
matchType: z.enum([ 'notContains',
'startsWith', 'endsWith',
'contains', 'equals',
'notContains', 'notEquals',
'endsWith', ]);
'equals',
'notEquals', export const FilterValue = z.object({
]), value: z.string().trim(),
matchType: FilterOperator,
}); });
const RowsResponse = z.object({ export const Filter = z.object({
column: z.string(),
value: FilterValue.optional().array(),
operator: z.enum(['and', 'or']),
});
export const RowsResponse = z.object({
// TODO: Can't this just be a string[]?
rows: z.any(), rows: z.any(),
totalRows: z.bigint(), totalRows: z.bigint().nonnegative(),
}); });
export type Column = z.infer<typeof Column>; export type Column = z.infer<typeof Column>;
export type SortColumn = z.infer<typeof SortColumn>; export type SortColumn = z.infer<typeof SortColumn>;
export type FilterValue = z.infer<typeof FilterValue>;
export type FilterOperator = z.infer<typeof FilterOperator>;
export type Filter = z.infer<typeof Filter>; export type Filter = z.infer<typeof Filter>;
export type RowsResponse = z.infer<typeof RowsResponse>; export type RowsResponse = z.infer<typeof RowsResponse>;
const sanitisedFileName = (file: File) => const sanitisedFileName = (file: File) =>
file.name.toLowerCase().replaceAll("'", '').replaceAll(/\s*/g, ''); file.name.toLowerCase().replaceAll("'", '').replaceAll(/\s*/g, '');
const sqlOperator = (operator: FilterOperator) => {
switch (operator) {
case 'startsWith':
case 'endsWith':
case 'contains':
case 'equals':
return 'ILIKE';
case 'notContains':
case 'notEquals':
return 'NOT ILIKE';
}
};
const prefix = (operator: FilterOperator) => {
switch (operator) {
case 'endsWith':
case 'contains':
case 'notContains':
return '%';
default:
return '';
}
};
const suffix = (operator: FilterOperator) => {
switch (operator) {
case 'startsWith':
case 'contains':
case 'notContains':
return '%';
default:
return '';
}
};
//https://www.npmjs.com/package/@duckdb/duckdb-wasm //https://www.npmjs.com/package/@duckdb/duckdb-wasm
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -127,19 +172,30 @@ export class DuckdbService {
): Promise<RowsResponse> { ): Promise<RowsResponse> {
const conn = await this.db.connect(); const conn = await this.db.connect();
try { try {
const totalRowResponse = await conn.query( const whereClause = this.getWhereClause(filters);
`SELECT COUNT(1) totalRows FROM ${sanitisedFileName(file)}`, const mappedFilterValues = filters.flatMap((filter) =>
filter.value
.filter((value) => value?.value)
.map(
(value) =>
`${prefix(value?.matchType!)}${value?.value}${suffix(value?.matchType!)}`,
),
); );
const rowCountQuery = `SELECT COUNT(1) totalRows FROM ${sanitisedFileName(file)} ${whereClause}`;
const totalRowStmt = await conn.prepare(rowCountQuery);
const totalRowResponse = await totalRowStmt.query(...mappedFilterValues);
const { totalRows } = totalRowResponse.get(0)?.toJSON()!; const { totalRows } = totalRowResponse.get(0)?.toJSON()!;
let query = `SELECT ${columns.map((column) => `"${column.name}"`).join(', ')} FROM ${sanitisedFileName(file)} `; 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(', ')}`;
} }
query += ` LIMIT ${numRows} OFFSET ${start}`; query += ` LIMIT ${numRows} OFFSET ${start}`;
const response = await conn.query(query); const stmt = await conn.prepare(query);
const rows = []; const rows = [];
for (let i = 0; i < response.numRows; i++) { for await (const batch of await stmt.send(...mappedFilterValues)) {
rows.push(response.get(i)?.toJSON()!); for (const row of batch) {
rows.push(row.toJSON()!);
}
} }
return { rows, totalRows }; return { rows, totalRows };
} catch (err) { } catch (err) {
@@ -149,4 +205,27 @@ export class DuckdbService {
conn.close(); conn.close();
} }
} }
// Where clause that gets attached to the main and rowCount selects.
private getWhereClause(filters: Filter[]) {
let query = '';
if (filters.length > 0) {
let and = 'WHERE';
for (const filter of filters) {
if (filter.value.find((value) => value?.value)) {
query += ` ${and} (`;
let or = '';
for (const value of filter.value) {
if (value?.value) {
query += ` ${or} ${filter.column} ${sqlOperator(value?.matchType!)} ? `;
or = filter.operator;
}
}
query += ') ';
and = 'and';
}
}
}
return query;
}
} }

View File

@@ -7,16 +7,20 @@ import {
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { Skeleton } from 'primeng/skeleton'; import { Skeleton } from 'primeng/skeleton';
import { TableLazyLoadEvent, TableModule } from 'primeng/table'; import { Table, TableLazyLoadEvent, TableModule } from 'primeng/table';
import { Column, DuckdbService } from '../duckdb.service'; import { Column, DuckdbService, Filter, FilterValue } from '../duckdb.service';
import { ButtonModule } from 'primeng/button';
import { IconField } from 'primeng/iconfield';
import { InputIconModule } from 'primeng/inputicon';
@Component({ @Component({
selector: 'app-file-viewer', selector: 'app-file-viewer',
standalone: true, standalone: true,
imports: [TableModule, Skeleton], imports: [TableModule, Skeleton, ButtonModule, IconField, InputIconModule],
template: ` template: `
@if (file() && columns().length > 0) { @if (file() && columns().length > 0) {
<p-table <p-table
#table
sortMode="multiple" sortMode="multiple"
[reorderableColumns]="true" [reorderableColumns]="true"
[resizableColumns]="true" [resizableColumns]="true"
@@ -34,6 +38,22 @@ import { Column, DuckdbService } from '../duckdb.service';
[multiSortMeta]="[{ field: columns()[0].name, order: 1 }]" [multiSortMeta]="[{ field: columns()[0].name, order: 1 }]"
stripedRows stripedRows
> >
<ng-template #caption>
<div class="flex">
<p-button
label="Reset"
[outlined]="true"
icon="pi pi-filter-slash"
(click)="clear(table)"
/>
<p-iconfield iconPosition="left" class="ml-auto">
<p-inputicon>
<i class="pi pi-search"></i>
</p-inputicon>
<input pInputText type="text" placeholder="Search keyword" />
</p-iconfield>
</div>
</ng-template>
<ng-template #header let-columns> <ng-template #header let-columns>
<tr> <tr>
@for (col of columns; track $index) { @for (col of columns; track $index) {
@@ -86,25 +106,16 @@ export class FileViewerComponent {
constructor() { constructor() {
effect(async () => { effect(async () => {
const file = this.file(); this.loadEmpty();
if (file) {
await this.duckdbService.addFile(file);
this.columns.set(await this.duckdbService.getColumns(file));
const rows = await this.duckdbService.getRows(
file,
0,
this.numRows,
this.columns(),
[],
[],
[],
);
const newValue = Array.from({ length: Number(rows.totalRows) });
this.currentValue.set(newValue);
}
}); });
} }
protected clear(table: Table) {
table.clear();
table.multiSortMeta = [{ field: this.columns()[0].name, order: 1 }];
table.sortMultiple();
}
protected async onLazyLoad(event: TableLazyLoadEvent) { protected async onLazyLoad(event: TableLazyLoadEvent) {
const file = this.file(); const file = this.file();
if (file) { if (file) {
@@ -117,17 +128,43 @@ export class FileViewerComponent {
name: meta.field, name: meta.field,
sortType: meta.order < 0 ? 'desc' : 'asc', sortType: meta.order < 0 ? 'desc' : 'asc',
})) ?? [], })) ?? [],
Array.isArray(event.filters) event.filters
? Object.values(event.filters).map((filter) => ({ ? Object.entries(event.filters).flatMap(([column, filter]) => {
value: Array.isArray(filter.value) if (Array.isArray(filter)) {
? filter.value const firstFilter = filter[0];
: [filter.value], return Filter.parse({
matchType: filter.matchMode, column,
operator: filter.operator, value: filter
})) .filter((f) => f.value != null)
.map((f) =>
FilterValue.parse({
value: f.value,
matchType: f.matchMode,
}),
),
operator: firstFilter.operator,
});
} else if (filter) {
return Filter.parse({
column,
value: [
FilterValue.parse({
value: filter.value,
matchType: filter.matchMode,
}),
],
operator: filter.operator,
});
}
return [];
})
: [], : [],
[], [],
); );
if (this.currentValue().length !== Number(rows.totalRows)) {
this.currentValue.set(Array.from({ length: Number(rows.totalRows) }));
return;
}
// First clear out existing data, don't want to risk loading entire file into memory // First clear out existing data, don't want to risk loading entire file into memory
// Keep data in previous/next page so when user scrolls between 2 pages they don't see missing data // Keep data in previous/next page so when user scrolls between 2 pages they don't see missing data
for (let i = 0; i < this.currentValue().length; i++) { for (let i = 0; i < this.currentValue().length; i++) {
@@ -138,9 +175,29 @@ export class FileViewerComponent {
this.currentValue()[i] = undefined; this.currentValue()[i] = undefined;
} }
} }
// Can't update the current value, otherwise we get an infinite loop // Can't update the current value, otherwise we get an infinite loop
this.currentValue().splice(event.first!, event.rows!, ...rows.rows); this.currentValue().splice(event.first!, event.rows!, ...rows.rows);
event.forceUpdate!(); event.forceUpdate!();
} }
} }
private async loadEmpty() {
const file = this.file();
if (file) {
await this.duckdbService.addFile(file);
this.columns.set(await this.duckdbService.getColumns(file));
const rows = await this.duckdbService.getRows(
file,
0,
this.numRows,
this.columns(),
[],
[],
[],
);
const newValue = Array.from({ length: Number(rows.totalRows) });
this.currentValue.set(newValue);
}
}
} }