This commit is contained in:
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user