feat(database): search and pagination on result query (#1432)

Adds a search bar to query result grids (match case / words / regex, previous/next occurrence, Filter rows) and a pager for console results (first/previous/next/last, page size). Later pages re-run the read-only query with a skip, which every driver reads past without decoding, so only one page crosses IPC; Last counts rows with the statement wrapped so a trailing line comment can't swallow the closing parenthesis.

Closes #1431

Co-authored-by: kidp2h <[email protected]>
Цей коміт міститься в:
Nguyen Thinh authored and GitHub committed 2026-11-05 12:54:15 +08:00
1 джерело 7856fabbfe
коміт 75c4ca0be8
22 files changed
+973 -97

No files matched your search

+113 -1
Переглянути файл
@@ -1,6 +1,7 @@
/**
* Pure helpers of the result views: IDEA's local filter and client-side sort
* of loaded rows, the Aggregates of a selection, and the series of the chart.
* of loaded rows, search in the grid, paging of console results, the
* Aggregates of a selection, and the series of the chart.
*/
import type { Cell } from "./model";
@@ -192,3 +193,114 @@ export function niceTicks(min: number, max: number, count = 5): number[] {
if (out.at(-1)! < hi) out.push(Number((out.at(-1)! + step).toPrecision(12)));
return out;
}
/** IDEA's search options of the grid's search bar. */
export interface SearchOptions {
caseSensitive: boolean;
wholeWord: boolean;
regex: boolean;
}
const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;
const WORD_CHAR = /[\p{L}\p{N}_$]/u;
/** A test of a cell's text against `query`; `null` without a query, `"invalid"` for a bad regex. */
export function cellMatcher(query: string, options: SearchOptions): ((text: string) => boolean) | null | "invalid" {
if (!query) return null;
const source = options.regex ? query : query.replace(REGEX_SPECIALS, "\\$&");
let re: RegExp;
try {
re = new RegExp(source, options.caseSensitive ? "gu" : "giu");
} catch {
return "invalid";
}
// Words: a match with no word character on either side (no lookbehind: older WebKit lacks it).
return (text) => {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
const start = m.index;
const end = start + m[0].length;
if (end === start) {
// Step a whole code point: with the `u` flag a lastIndex inside a surrogate pair
// snaps back to the pair's start, which would match the same empty string forever.
re.lastIndex = start + ((text.codePointAt(start) ?? 0) > 0xffff ? 2 : 1);
if (!options.wholeWord) return true;
continue;
}
if (!options.wholeWord || (!WORD_CHAR.test(text[start - 1] ?? "") && !WORD_CHAR.test(text[end] ?? "")))
return true;
}
return false;
};
}
/** The cells matching `test`, row by row; `row` is the position in `rows` (display order). NULL never matches. */
export function findCells(
rows: readonly number[],
colCount: number,
value: (row: number, col: number) => Cell,
test: (text: string) => boolean,
): Array<{ row: number; col: number }> {
const out: Array<{ row: number; col: number }> = [];
rows.forEach((r, row) => {
for (let col = 0; col < colCount; col++) {
const v = value(r, col);
if (v !== null && test(v)) out.push({ row, col });
}
});
return out;
}
/** Whether any cell of loaded row `r` matches `test`. */
export function rowMatches(
r: number,
colCount: number,
value: (row: number, col: number) => Cell,
test: (text: string) => boolean,
): boolean {
for (let col = 0; col < colCount; col++) {
const v = value(r, col);
if (v !== null && test(v)) return true;
}
return false;
}
export interface PageState {
/** Zero-based page shown. */
page: number;
pageSize: number;
/** Rows on the page. */
rows: number;
/** More rows follow the page. */
hasMore: boolean;
/** Counted rows of the whole query, when known. */
total: number | null;
}
export interface PageWindow {
/** One-based numbers of the first and last row shown (0 and 0 on an empty page). */
first: number;
last: number;
/** Rows of the whole query: known once counted or on reaching the end. */
total: number | null;
/** Zero-based last page, when the total is known. */
lastPage: number | null;
}
/** What the pager shows ("1-500 of 501+"). */
export function pageWindow({ page, pageSize, rows, hasMore, total }: PageState): PageWindow {
const offset = page * pageSize;
const known = total ?? (hasMore ? null : offset + rows);
return {
first: rows ? offset + 1 : 0,
last: offset + rows,
total: known,
lastPage: known === null ? null : Math.max(0, Math.ceil(known / pageSize) - 1),
};
}
/** A page is read by running the query again: the backend reads past the rows before it and returns only the page. */
export function pageFetch(page: number, pageSize: number): { maxRows: number; skip: number } {
return { maxRows: pageSize, skip: page * pageSize };
}