дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
7856fabbfe
коміт
75c4ca0be8
22 files changed
+973
-97
No files matched your search
@@ -4,20 +4,24 @@
|
||||
* client-side sorting when the caller does not sort on the server, and the
|
||||
* Record / Value / Aggregates side panel following the selection.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState, type KeyboardEvent, type RefObject } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Button, Checkbox, Icon, IconButton, Input, Popup, Select, Tabs, T, type MenuItem } from "@/ui";
|
||||
import { Button, Checkbox, Icon, IconButton, Input, Popup, SearchField, Select, Tabs, T, type MenuItem } from "@/ui";
|
||||
import { EXPORT_FORMATS, exportData, type ExportData, type ExportFormat } from "./export";
|
||||
import { Grid, type GridColumn, type GridProps, type GridRange } from "./Grid";
|
||||
import type { Cell } from "./model";
|
||||
import {
|
||||
aggregates,
|
||||
cellMatcher,
|
||||
distinctValues,
|
||||
findCells,
|
||||
formatNumber,
|
||||
rowMatches,
|
||||
toggleFilterValue,
|
||||
visibleRows,
|
||||
type LocalFilter,
|
||||
type LocalSort,
|
||||
type SearchOptions,
|
||||
} from "./resultView";
|
||||
import { openDialog } from "./store";
|
||||
import { hasLineBreak } from "./cellPreview";
|
||||
@@ -83,9 +87,23 @@ export interface DataViewProps extends Pick<
|
||||
/** The side panel shown (`null`: hidden). */
|
||||
panel: DataPanel | null;
|
||||
onPanelChange: (panel: DataPanel | null) => void;
|
||||
/** The search bar is shown (the toolbar's search button, ⌘F in the grid). */
|
||||
searchOpen?: boolean;
|
||||
onSearchOpenChange?: (open: boolean) => void;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
const NO_SEARCH: SearchOptions = { caseSensitive: false, wholeWord: false, regex: false };
|
||||
|
||||
interface SearchState {
|
||||
query: string;
|
||||
options: SearchOptions;
|
||||
/** Show only the rows with a match (IDEA's Filter rows). */
|
||||
filterRows: boolean;
|
||||
/** Index of the current match. */
|
||||
current: number;
|
||||
}
|
||||
|
||||
export function DataView(props: DataViewProps) {
|
||||
const { columns, rowCount, value, fixedRows = rowCount, panel, onPanelChange } = props;
|
||||
const columnKey = columns.map((c) => c.name).join("\u0000");
|
||||
@@ -102,15 +120,49 @@ export function DataView(props: DataViewProps) {
|
||||
|
||||
const local = !props.onSort;
|
||||
// Display row → loaded row; `null` keeps the identity (no filter, no local sort).
|
||||
const order = useMemo(
|
||||
const shaped = useMemo(
|
||||
() =>
|
||||
filter.size || (local && localSort)
|
||||
? visibleRows(rowCount, value, filter, local ? localSort : null, fixedRows)
|
||||
: null,
|
||||
[rowCount, value, filter, local, localSort, fixedRows],
|
||||
);
|
||||
|
||||
// Search covers the loaded rows only (the page shown).
|
||||
const { searchOpen = false, onSearchOpenChange } = props;
|
||||
const [search, setSearch] = useState<SearchState>({ query: "", options: NO_SEARCH, filterRows: false, current: 0 });
|
||||
const searchInput = useRef<HTMLInputElement>(null);
|
||||
const matcher = useMemo(
|
||||
() => (searchOpen ? cellMatcher(search.query, search.options) : null),
|
||||
[searchOpen, search.query, search.options],
|
||||
);
|
||||
const test = typeof matcher === "function" ? matcher : null;
|
||||
const order = useMemo(() => {
|
||||
if (!test || !search.filterRows) return shaped;
|
||||
const base = shaped ?? Array.from({ length: rowCount }, (_, i) => i);
|
||||
return base.filter((r) => rowMatches(r, columns.length, value, test));
|
||||
}, [shaped, test, search.filterRows, rowCount, columns.length, value]);
|
||||
const at = (r: number) => (order ? order[r]! : r);
|
||||
const shown = order ? order.length : rowCount;
|
||||
const matches = useMemo(
|
||||
() => (test ? findCells(order ?? Array.from({ length: rowCount }, (_, i) => i), columns.length, value, test) : []),
|
||||
[test, order, rowCount, columns.length, value],
|
||||
);
|
||||
const matchSet = useMemo(() => new Set(matches.map((m) => `${m.row}:${m.col}`)), [matches]);
|
||||
const currentMatch = matches.length ? matches[Math.min(search.current, matches.length - 1)]! : null;
|
||||
const step = (by: number) =>
|
||||
matches.length &&
|
||||
setSearch((s) => ({
|
||||
...s,
|
||||
current: (Math.min(s.current, matches.length - 1) + by + matches.length) % matches.length,
|
||||
}));
|
||||
const openSearch = () => {
|
||||
onSearchOpenChange?.(true);
|
||||
requestAnimationFrame(() => {
|
||||
searchInput.current?.focus();
|
||||
searchInput.current?.select();
|
||||
});
|
||||
};
|
||||
|
||||
const onSort = local
|
||||
? (column: string) => {
|
||||
@@ -129,32 +181,50 @@ export function DataView(props: DataViewProps) {
|
||||
|
||||
return (
|
||||
<div className={styles.dataView}>
|
||||
<div className={clsx(styles.fill, styles.gridHost)}>
|
||||
<Grid
|
||||
aria-label={props["aria-label"]}
|
||||
columns={columns}
|
||||
rowCount={shown}
|
||||
value={(r, c) => value(at(r), c)}
|
||||
rowState={props.rowState && ((r) => props.rowState!(at(r)))}
|
||||
isModified={props.isModified && ((r, c) => props.isModified!(at(r), c))}
|
||||
editable={props.editable}
|
||||
onEdit={props.onEdit && ((r, c, v) => props.onEdit!(at(r), c, v))}
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
rowMenu={props.rowMenu && ((rows) => props.rowMenu!(rows.map(at)))}
|
||||
rowNumber={(r) => (props.firstRowNumber ?? 1) + at(r)}
|
||||
onSelectionChange={setRange}
|
||||
onFilter={(col, anchor) => setFilterAt({ col, anchor })}
|
||||
filtered={(col) => filter.has(col)}
|
||||
/>
|
||||
{filter.size > 0 && (
|
||||
<div className={styles.filterPill} role="status">
|
||||
{tx`${shown} of ${rowCount} rows`}
|
||||
<button type="button" className={styles.filterPillClear} onClick={() => setFilter(new Map())}>
|
||||
<T>Clear local filter</T>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.dataMain}>
|
||||
{searchOpen && (
|
||||
<GridSearchBar
|
||||
inputRef={searchInput}
|
||||
search={search}
|
||||
onChange={(p) => setSearch((s) => ({ ...s, ...p, current: 0 }))}
|
||||
count={matches.length}
|
||||
active={currentMatch ? Math.min(search.current, matches.length - 1) : -1}
|
||||
invalid={matcher === "invalid"}
|
||||
onNext={() => step(1)}
|
||||
onPrevious={() => step(-1)}
|
||||
onClose={() => onSearchOpenChange?.(false)}
|
||||
/>
|
||||
)}
|
||||
<div className={clsx(styles.fill, styles.gridHost)}>
|
||||
<Grid
|
||||
isMatch={test ? (r, c) => matchSet.has(`${r}:${c}`) : undefined}
|
||||
currentMatch={currentMatch}
|
||||
onFind={onSearchOpenChange && openSearch}
|
||||
aria-label={props["aria-label"]}
|
||||
columns={columns}
|
||||
rowCount={shown}
|
||||
value={(r, c) => value(at(r), c)}
|
||||
rowState={props.rowState && ((r) => props.rowState!(at(r)))}
|
||||
isModified={props.isModified && ((r, c) => props.isModified!(at(r), c))}
|
||||
editable={props.editable}
|
||||
onEdit={props.onEdit && ((r, c, v) => props.onEdit!(at(r), c, v))}
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
rowMenu={props.rowMenu && ((rows) => props.rowMenu!(rows.map(at)))}
|
||||
rowNumber={(r) => (props.firstRowNumber ?? 1) + at(r)}
|
||||
onSelectionChange={setRange}
|
||||
onFilter={(col, anchor) => setFilterAt({ col, anchor })}
|
||||
filtered={(col) => filter.has(col)}
|
||||
/>
|
||||
{filter.size > 0 && (
|
||||
<div className={styles.filterPill} role="status">
|
||||
{tx`${shown} of ${rowCount} rows`}
|
||||
<button type="button" className={styles.filterPillClear} onClick={() => setFilter(new Map())}>
|
||||
<T>Clear local filter</T>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{filterAt && (
|
||||
<FilterPopup
|
||||
@@ -189,6 +259,109 @@ export function DataView(props: DataViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** IDEA's search bar above the grid: Match Case / Words / Regex, n/m, previous / next, Filter rows. */
|
||||
function GridSearchBar(props: {
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
search: SearchState;
|
||||
onChange: (patch: Partial<Omit<SearchState, "current">>) => void;
|
||||
count: number;
|
||||
/** Zero-based current match; -1 when there is none. */
|
||||
active: number;
|
||||
invalid: boolean;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { search, count, invalid, active, inputRef, onChange, onNext, onPrevious, onClose } = props;
|
||||
const none = !!search.query && !invalid && count === 0;
|
||||
const toggle = (key: keyof SearchOptions) =>
|
||||
onChange({ options: { ...search.options, [key]: !search.options[key] } });
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
} else if (e.key === "Enter" || e.key === "F3") {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) onPrevious();
|
||||
else onNext();
|
||||
} else if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
const key =
|
||||
e.code === "KeyC" ? "caseSensitive" : e.code === "KeyW" ? "wholeWord" : e.code === "KeyX" ? "regex" : null;
|
||||
if (key) {
|
||||
e.preventDefault();
|
||||
toggle(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={styles.gridSearch} role="search" aria-label={tx("Search in results")} onKeyDown={onKeyDown}>
|
||||
<SearchField
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
className={styles.gridSearchField}
|
||||
value={search.query}
|
||||
onChange={(query) => onChange({ query })}
|
||||
placeholder="Search in results"
|
||||
aria-label="Search in results"
|
||||
invalid={invalid || none}
|
||||
toggles={[
|
||||
{
|
||||
id: "case",
|
||||
icon: "case",
|
||||
title: "Match Case",
|
||||
shortcut: "⌥C",
|
||||
pressed: search.options.caseSensitive,
|
||||
onToggle: () => toggle("caseSensitive"),
|
||||
},
|
||||
{
|
||||
id: "word",
|
||||
icon: "word",
|
||||
title: "Words",
|
||||
shortcut: "⌥W",
|
||||
pressed: search.options.wholeWord,
|
||||
onToggle: () => toggle("wholeWord"),
|
||||
},
|
||||
{
|
||||
id: "regex",
|
||||
icon: "regex",
|
||||
title: "Regex",
|
||||
shortcut: "⌥X",
|
||||
pressed: search.options.regex,
|
||||
onToggle: () => toggle("regex"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<span className={clsx(styles.gridSearchCount, (invalid || none) && styles.gridSearchNone)} aria-live="polite">
|
||||
{invalid ? tx("Bad regex") : !search.query ? "" : count === 0 ? tx("0 results") : `${active + 1}/${count}`}
|
||||
</span>
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
title="Previous Occurrence"
|
||||
shortcut="⇧↵"
|
||||
size="small"
|
||||
disabled={count === 0}
|
||||
onClick={onPrevious}
|
||||
/>
|
||||
<IconButton
|
||||
icon="arrow-down"
|
||||
title="Next Occurrence"
|
||||
shortcut="↵"
|
||||
size="small"
|
||||
disabled={count === 0}
|
||||
onClick={onNext}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={search.filterRows}
|
||||
onChange={(filterRows) => onChange({ filterRows })}
|
||||
label={tx("Filter rows")}
|
||||
/>
|
||||
<span className={styles.spacer} />
|
||||
<IconButton icon="close" title="Close" shortcut="⎋" size="small" onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterPopup(props: {
|
||||
anchor: HTMLElement;
|
||||
column: string;
|
||||
|
||||
@@ -560,21 +560,6 @@
|
||||
.gridHost {
|
||||
position: relative;
|
||||
}
|
||||
/* IDEA's floating row count at the bottom of a result. */
|
||||
.rowPill {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 12px;
|
||||
transform: translateX(-50%);
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--border-popup, var(--border));
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-popup, var(--bg-elev));
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
box-shadow: var(--shadow-popup, none);
|
||||
pointer-events: none;
|
||||
}
|
||||
.resultSource {
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
@@ -1260,3 +1245,77 @@
|
||||
overflow-wrap: anywhere;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* The grid beside the Record / Value / Aggregates panel, under its search bar. */
|
||||
.dataMain {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* IDEA's search bar above a result grid. */
|
||||
.gridSearch {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
.gridSearchField {
|
||||
flex: 0 1 360px;
|
||||
min-width: 160px;
|
||||
}
|
||||
.gridSearchCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
padding: 0 6px;
|
||||
white-space: nowrap;
|
||||
min-width: 56px;
|
||||
}
|
||||
.gridSearchNone {
|
||||
color: var(--danger);
|
||||
}
|
||||
.cellMatch {
|
||||
background: var(--bg-text-search-result);
|
||||
}
|
||||
.cellMatchCurrent {
|
||||
background: var(--bg-text-search-result-current);
|
||||
}
|
||||
|
||||
/* IDEA's floating pager at the bottom of a result: |< < 1-500 of 501+ > >| and the page size. */
|
||||
.resultPager {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 12px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--border-popup, var(--border));
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-popup, var(--bg-elev));
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
box-shadow: var(--shadow-popup, none);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.resultPagerRange {
|
||||
padding: 0 6px;
|
||||
}
|
||||
.resultPagerCount {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.resultPagerCount:disabled {
|
||||
color: inherit;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { DataView } from "./DataView";
|
||||
import { Grid } from "./Grid";
|
||||
import { CELL_PREVIEW_LIMIT, cellPreview } from "./cellPreview";
|
||||
import { exportData } from "./export";
|
||||
@@ -175,3 +177,60 @@ describe("multi-line database cells", () => {
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("result grid search", () => {
|
||||
const columns = [
|
||||
{ name: "city", type: "text" },
|
||||
{ name: "n", type: "int" },
|
||||
];
|
||||
const data = [
|
||||
["Shanghai", "1"],
|
||||
["Beijing", "2"],
|
||||
["Shenzhen", "3"],
|
||||
["Hangzhou", "4"],
|
||||
];
|
||||
const value = (r: number, c: number) => data[r]?.[c] ?? null;
|
||||
|
||||
it("opens the search bar on Ctrl+F and highlights the matches of the page", () => {
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<DataView
|
||||
columns={columns}
|
||||
rowCount={4}
|
||||
value={value}
|
||||
panel={null}
|
||||
onPanelChange={() => {}}
|
||||
searchOpen={open}
|
||||
onSearchOpenChange={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
fireEvent.keyDown(screen.getByRole("grid"), { key: "f", ctrlKey: true });
|
||||
const input = screen.getByRole("textbox", { name: "Search in results" });
|
||||
fireEvent.change(input, { target: { value: "sh" } });
|
||||
const marked = () =>
|
||||
screen
|
||||
.getAllByRole("gridcell")
|
||||
.filter((c) => /cellMatch/.test(c.className))
|
||||
.map((c) => c.textContent);
|
||||
expect(marked()).toEqual(["Shanghai", "Shenzhen"]);
|
||||
expect(screen.getByText("1/2")).toBeTruthy();
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByText("2/2")).toBeTruthy();
|
||||
|
||||
// Match Case: "sh" no longer matches "Sh…".
|
||||
fireEvent.click(screen.getByRole("button", { name: /Match Case/ }));
|
||||
expect(marked()).toEqual([]);
|
||||
expect(screen.getByText("0 results")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Match Case/ }));
|
||||
|
||||
// Filter rows keeps only the rows with a match.
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Filter rows" }));
|
||||
expect(screen.getAllByRole("row").length - 1).toBe(2);
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(screen.queryByRole("search")).toBeNull();
|
||||
expect(screen.getAllByRole("row").length - 1).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,12 @@ export interface GridProps {
|
||||
/** The header's filter button (IDEA's local filter); `filtered` marks the columns with one. */
|
||||
onFilter?: (col: number, anchor: HTMLElement) => void;
|
||||
filtered?: (col: number) => boolean;
|
||||
/** Cells matching the search bar's query. */
|
||||
isMatch?: (row: number, col: number) => boolean;
|
||||
/** The search bar's current match: selected and scrolled into view when it changes. */
|
||||
currentMatch?: { row: number; col: number } | null;
|
||||
/** ⌘F / Ctrl+F in the grid (opens the search bar). */
|
||||
onFind?: () => void;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
@@ -154,6 +160,15 @@ export function Grid(props: GridProps) {
|
||||
estimateSize: () => ROW_H,
|
||||
overscan: 12,
|
||||
});
|
||||
const { currentMatch } = props;
|
||||
useEffect(() => {
|
||||
if (!currentMatch) return;
|
||||
const { row, col } = currentMatch;
|
||||
setSel({ r0: row, c0: col, r1: row, c1: col });
|
||||
virtualizer.scrollToIndex(row, { align: "auto" });
|
||||
// Follows the match only, not the virtualizer's identity.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentMatch?.row, currentMatch?.col]);
|
||||
const totalWidth = useMemo(() => NUM_W + widths.reduce((a, b) => a + b, 0), [widths]);
|
||||
const template = `${NUM_W}px ${widths.map((w) => `${w}px`).join(" ")}`;
|
||||
|
||||
@@ -211,6 +226,11 @@ export function Grid(props: GridProps) {
|
||||
copy("tsv", false);
|
||||
return;
|
||||
}
|
||||
if (mod && e.key.toLowerCase() === "f" && props.onFind) {
|
||||
e.preventDefault();
|
||||
props.onFind();
|
||||
return;
|
||||
}
|
||||
if (mod && e.key.toLowerCase() === "a") {
|
||||
e.preventDefault();
|
||||
if (rowCount && columns.length) setSel({ r0: 0, c0: 0, r1: rowCount - 1, c1: columns.length - 1 });
|
||||
@@ -466,6 +486,8 @@ export function Grid(props: GridProps) {
|
||||
className={clsx(
|
||||
styles.cell,
|
||||
numeric[c] && styles.cellNumber,
|
||||
props.isMatch?.(r, c) && styles.cellMatch,
|
||||
currentMatch?.row === r && currentMatch.col === c && styles.cellMatchCurrent,
|
||||
selected(r, c) && styles.cellSelected,
|
||||
props.isModified?.(r, c) && styles.cellModified,
|
||||
)}
|
||||
|
||||
@@ -7,16 +7,22 @@ import { copySelectionOnKeyDown } from "@/ui/selectableText";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { DbPlanNode } from "@/platform/ipc";
|
||||
import { EmptyState, Icon, IconButton, Spinner, Tabs, Tree, showContextMenu, T } from "@/ui";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { EmptyState, Icon, IconButton, Select, Spinner, Tabs, Tree, showContextMenu, T } from "@/ui";
|
||||
import { ChartView } from "./ChartView";
|
||||
import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView";
|
||||
import type { ExportData, ExportFormat } from "./export";
|
||||
import { pageWindow } from "./resultView";
|
||||
import { analyze } from "./sql/analyze";
|
||||
import {
|
||||
OUTPUT_TAB,
|
||||
clearOutput,
|
||||
closeResult,
|
||||
countResult,
|
||||
countable,
|
||||
loadResultPage,
|
||||
outcomeSummary,
|
||||
pageable,
|
||||
rerunResult,
|
||||
sourceById,
|
||||
targetLabel,
|
||||
@@ -72,9 +78,88 @@ function queryTable(text: string, data: Pick<ExportData, "driver">): string {
|
||||
return a.refs.find((r) => r.depth === 0 && r.kind === "table")?.name ?? "MY_TABLE";
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [10, 100, 500, 1000];
|
||||
|
||||
/** IDEA's pager at the bottom of a result: |< < 1-500 of 501+ > >| and the page size; paging runs the query again. */
|
||||
function Pager({ tab }: { tab: ResultTab }) {
|
||||
const r = tab.outcome.result!;
|
||||
const page = tab.page ?? 0;
|
||||
const pageSize = tab.pageSize ?? Math.max(r.rows.length, 1);
|
||||
const state = { page, pageSize, rows: r.rows.length, hasMore: r.hasMore, total: tab.total ?? null };
|
||||
const w = pageWindow(state);
|
||||
const canPage = pageable(tab) && !tab.busy;
|
||||
const go = (p: number, size = pageSize) => void loadResultPage(tab.id, p, size);
|
||||
const goLast = async () => {
|
||||
let lastPage = w.lastPage;
|
||||
if (lastPage === null) {
|
||||
const total = await countResult(tab.id);
|
||||
if (total === null) return;
|
||||
lastPage = pageWindow({ ...state, total }).lastPage;
|
||||
}
|
||||
if (lastPage !== null && lastPage !== page) go(lastPage);
|
||||
};
|
||||
const { first, last } = w;
|
||||
const total = w.total === null ? `${w.last + 1}+` : String(w.total);
|
||||
const sizes = PAGE_SIZES.includes(pageSize) ? PAGE_SIZES : [...PAGE_SIZES, pageSize].sort((a, b) => a - b);
|
||||
return (
|
||||
<div
|
||||
className={styles.resultPager}
|
||||
role="toolbar"
|
||||
aria-label={tx("Result pages")}
|
||||
title={outcomeSummary(tab.outcome)}
|
||||
>
|
||||
<IconButton
|
||||
icon="page-first"
|
||||
title="First Page"
|
||||
size="small"
|
||||
disabled={!canPage || page === 0}
|
||||
onClick={() => go(0)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-left"
|
||||
title="Previous Page"
|
||||
size="small"
|
||||
disabled={!canPage || page === 0}
|
||||
onClick={() => go(page - 1)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.resultPagerRange, styles.resultPagerCount)}
|
||||
disabled={w.total !== null || !canPage || !countable(tab)}
|
||||
title={w.total === null ? tx("Count rows") : undefined}
|
||||
onClick={() => void countResult(tab.id)}
|
||||
>
|
||||
{tx`${first}-${last} of ${total}`}
|
||||
</button>
|
||||
<IconButton
|
||||
icon="chevron-right"
|
||||
title="Next Page"
|
||||
size="small"
|
||||
disabled={!canPage || !r.hasMore}
|
||||
onClick={() => go(page + 1)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="page-last"
|
||||
title="Last Page"
|
||||
size="small"
|
||||
disabled={!canPage || (!r.hasMore && w.total !== null) || (w.total === null && !countable(tab))}
|
||||
onClick={() => void goLast()}
|
||||
/>
|
||||
<Select
|
||||
aria-label="Page size"
|
||||
value={String(pageSize)}
|
||||
options={sizes.map((v) => ({ value: String(v), label: String(v) }))}
|
||||
onChange={(v) => go(0, Number(v))}
|
||||
disabled={!canPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Result({ tab }: { tab: ResultTab }) {
|
||||
const [view, setView] = useState<"table" | "chart">("table");
|
||||
const [panel, setPanel] = useState<DataPanel | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [extractor, setExtractor] = useState<ExportFormat>("csv");
|
||||
const r = tab.outcome.result;
|
||||
const columns = useMemo(() => (r ? r.columns.map((c) => ({ name: c.name, type: c.typeName })) : []), [r]);
|
||||
@@ -94,6 +179,15 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
|
||||
<span className={styles.toolbarSeparator} />
|
||||
<IconButton icon="refresh" title="Rerun Query" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
|
||||
{view === "table" && (
|
||||
<IconButton
|
||||
icon="search"
|
||||
title="Search"
|
||||
shortcut={shortcutLabel("find.inFile")}
|
||||
aria-pressed={searchOpen}
|
||||
onClick={() => setSearchOpen(!searchOpen)}
|
||||
/>
|
||||
)}
|
||||
{tab.busy && <Spinner size={12} />}
|
||||
<span className={styles.spacer} />
|
||||
<span className={styles.resultSource} title={tab.outcome.statement.text}>
|
||||
@@ -121,12 +215,12 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
value={value}
|
||||
panel={panel}
|
||||
onPanelChange={setPanel}
|
||||
searchOpen={searchOpen}
|
||||
onSearchOpenChange={setSearchOpen}
|
||||
firstRowNumber={(tab.page ?? 0) * (tab.pageSize ?? 0) + 1}
|
||||
aria-label={tab.title}
|
||||
/>
|
||||
<div className={styles.rowPill} role="status" title={outcomeSummary(tab.outcome)}>
|
||||
{r.rows.length === 1 ? tx`${r.rows.length} row` : tx`${r.rows.length} rows`}
|
||||
{r.hasMore ? ` ${tx("(limit reached)")}` : ""}
|
||||
</div>
|
||||
<Pager tab={tab} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { openDialog, sourceById, withConnection } from "./store";
|
||||
import type { TableRef } from "./tableInput";
|
||||
import styles from "./Database.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
|
||||
const PAGE_SIZES = ["100", "500", "1000", "5000"];
|
||||
|
||||
@@ -46,6 +47,7 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const [edits, setEdits] = useState<PendingEdits>(emptyEdits);
|
||||
const [panel, setPanel] = useState<DataPanel | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [extractor, setExtractor] = useState<ExportFormat>("csv");
|
||||
// The ORDER BY field while it's being edited; it shows the sort otherwise.
|
||||
const [orderDraft, setOrderDraft] = useState<string | null>(null);
|
||||
@@ -254,6 +256,13 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
disabled={!editable}
|
||||
onClick={() => openDialog({ kind: "import", id: ref.id, schema: ref.schema, table: ref.table })}
|
||||
/>
|
||||
<IconButton
|
||||
icon="search"
|
||||
title="Search"
|
||||
shortcut={shortcutLabel("find.inFile")}
|
||||
aria-pressed={searchOpen}
|
||||
onClick={() => setSearchOpen(!searchOpen)}
|
||||
/>
|
||||
<PanelToggle panel={panel} onChange={setPanel} />
|
||||
</div>
|
||||
<div className={styles.filterBar}>
|
||||
@@ -322,6 +331,8 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
value={value}
|
||||
panel={panel}
|
||||
onPanelChange={setPanel}
|
||||
searchOpen={searchOpen}
|
||||
onSearchOpenChange={setSearchOpen}
|
||||
firstRowNumber={offset + 1}
|
||||
rowState={(r) => (r >= loaded ? "inserted" : edits.deletes.has(r) ? "deleted" : null)}
|
||||
isModified={(r, c) => !!edits.updates.get(r)?.has(names[c]!)}
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
import { dropSql } from "./objects";
|
||||
import {
|
||||
aggregates,
|
||||
cellMatcher,
|
||||
findCells,
|
||||
pageFetch,
|
||||
pageWindow,
|
||||
chartSeries,
|
||||
compareCells,
|
||||
distinctValues,
|
||||
@@ -219,3 +223,81 @@ describe("dropping schema objects", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("grid search", () => {
|
||||
const opts = { caseSensitive: false, wholeWord: false, regex: false };
|
||||
|
||||
it("matches cell text with Match Case, Words and Regex", () => {
|
||||
const m = cellMatcher("shang", opts);
|
||||
expect(typeof m).toBe("function");
|
||||
const test = m as (v: string) => boolean;
|
||||
expect(test("Shanghai")).toBe(true);
|
||||
expect(test("Beijing")).toBe(false);
|
||||
expect((cellMatcher("shang", { ...opts, caseSensitive: true }) as (v: string) => boolean)("Shanghai")).toBe(false);
|
||||
const word = cellMatcher("23", { ...opts, wholeWord: true }) as (v: string) => boolean;
|
||||
expect(word("23")).toBe(true);
|
||||
expect(word("123")).toBe(false);
|
||||
expect(word("a 23 b")).toBe(true);
|
||||
const re = cellMatcher(String.raw`^\d\.\d$`, { ...opts, regex: true }) as (v: string) => boolean;
|
||||
expect(re("2.5")).toBe(true);
|
||||
expect(re("10")).toBe(false);
|
||||
// A dot is literal without Regex.
|
||||
expect((cellMatcher("2.5", opts) as (v: string) => boolean)("215")).toBe(false);
|
||||
});
|
||||
|
||||
it("reports no query and a bad regex", () => {
|
||||
expect(cellMatcher("", opts)).toBeNull();
|
||||
expect(cellMatcher("(", { ...opts, regex: true })).toBe("invalid");
|
||||
});
|
||||
|
||||
it("steps over astral characters on empty matches in Words mode", () => {
|
||||
for (const query of ["x*", "|", "^", String.raw`\d*`, String.raw`\b`]) {
|
||||
const m = cellMatcher(query, { ...opts, regex: true, wholeWord: true }) as (v: string) => boolean;
|
||||
expect(() => m("a😀b")).not.toThrow();
|
||||
expect(typeof m("😀")).toBe("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
it("finds the matching cells row by row, skipping NULL", () => {
|
||||
const m = cellMatcher("i", opts) as (v: string) => boolean;
|
||||
expect(findCells([0, 1, 2, 3], 3, value, m)).toEqual([
|
||||
{ row: 0, col: 1 },
|
||||
{ row: 1, col: 1 },
|
||||
{ row: 2, col: 1 },
|
||||
]);
|
||||
// Display rows are positions in `rows`.
|
||||
expect(findCells([3, 1], 3, value, m)).toEqual([{ row: 1, col: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("result pages", () => {
|
||||
it("describes the rows of a page, IDEA style", () => {
|
||||
expect(pageWindow({ page: 0, pageSize: 500, rows: 500, hasMore: true, total: null })).toEqual({
|
||||
first: 1,
|
||||
last: 500,
|
||||
total: null,
|
||||
lastPage: null,
|
||||
});
|
||||
expect(pageWindow({ page: 1, pageSize: 500, rows: 1, hasMore: false, total: null })).toEqual({
|
||||
first: 501,
|
||||
last: 501,
|
||||
total: 501,
|
||||
lastPage: 1,
|
||||
});
|
||||
expect(pageWindow({ page: 0, pageSize: 100, rows: 100, hasMore: true, total: 1234 })).toMatchObject({
|
||||
total: 1234,
|
||||
lastPage: 12,
|
||||
});
|
||||
expect(pageWindow({ page: 0, pageSize: 100, rows: 0, hasMore: false, total: null })).toEqual({
|
||||
first: 0,
|
||||
last: 0,
|
||||
total: 0,
|
||||
lastPage: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("slices the rows of a page from a fetch of every row up to it", () => {
|
||||
expect(pageFetch(0, 500)).toEqual({ maxRows: 500, skip: 0 });
|
||||
expect(pageFetch(2, 100)).toEqual({ maxRows: 100, skip: 200 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isReadOnlyQuery } from "./readOnly";
|
||||
|
||||
describe("isReadOnlyQuery", () => {
|
||||
it("accepts queries that only read", () => {
|
||||
for (const text of [
|
||||
"SELECT * FROM t",
|
||||
"with x as (select 1) select * from x",
|
||||
"VALUES (1), (2)",
|
||||
"show tables",
|
||||
"(select 1) union (select 2)",
|
||||
])
|
||||
expect(isReadOnlyQuery(text, "postgres"), text).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects statements that change data", () => {
|
||||
for (const text of [
|
||||
"DELETE FROM t RETURNING *",
|
||||
"UPDATE t SET a = 1 RETURNING a",
|
||||
"INSERT INTO t VALUES (1) RETURNING id",
|
||||
"with d as (delete from t returning *) select * from d",
|
||||
"select * into t2 from t",
|
||||
"select * from t for update",
|
||||
"select nextval('s')",
|
||||
])
|
||||
expect(isReadOnlyQuery(text, "postgres"), text).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { DbDriver } from "@/platform/ipc";
|
||||
import { lex } from "./lexer";
|
||||
|
||||
/** Statements that only read. */
|
||||
const READ_VERBS = new Set(["SELECT", "WITH", "VALUES", "TABLE", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "PRAGMA"]);
|
||||
/** Words that make a reading statement write (data-modifying CTEs, SELECT INTO, row locks, sequences). */
|
||||
const WRITES = new Set(["INSERT", "UPDATE", "DELETE", "MERGE", "INTO", "NEXTVAL", "SETVAL", "CALL", "EXEC", "EXECUTE"]);
|
||||
|
||||
/** Whether running `text` again only reads (paging a result re-runs its query). */
|
||||
export function isReadOnlyQuery(text: string, driver: DbDriver): boolean {
|
||||
const tokens = lex(text, driver).tokens;
|
||||
const first = tokens.find((t) => t.text !== "(");
|
||||
if (!first || first.kind !== "word" || !READ_VERBS.has(first.upper)) return false;
|
||||
return !tokens.some((t) => t.kind === "word" && WRITES.has(t.upper));
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import type { ExportData, ExportFormat } from "./export";
|
||||
import type { DbObjectRef } from "./objects";
|
||||
import { formatted } from "./sql/format";
|
||||
import { bindParams, findParams } from "./sql/params";
|
||||
import { isReadOnlyQuery } from "./sql/readOnly";
|
||||
import { pageFetch } from "./resultView";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
export const DB_TOOL_WINDOW = "database";
|
||||
@@ -58,6 +60,11 @@ export interface ResultTab {
|
||||
plan?: DbPlanNode[];
|
||||
/** Being re-run. */
|
||||
busy?: boolean;
|
||||
/** Zero-based page of the result shown in `outcome` (IDEA's paging re-runs the query). */
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** Rows of the whole query, once counted. */
|
||||
total?: number | null;
|
||||
}
|
||||
|
||||
/** One line of the consoles' Output log (IDEA's Database Console output). */
|
||||
@@ -643,12 +650,19 @@ function promptOf(id: string): string {
|
||||
return useDatabase.getState().completion[id]?.defaultSchema || sourceById(id)?.name || id;
|
||||
}
|
||||
|
||||
/** Runs `statements` on `id`, logging to Output; errors stop the batch (backend). */
|
||||
async function run(id: string, statements: SqlStatement[], request: RequestId): Promise<DbStatementOutcome[]> {
|
||||
/**
|
||||
* Runs `statements` on `id`, logging to Output; errors stop the batch (backend).
|
||||
* Results keep `maxRows` rows after the first `skip`, which the backend reads past.
|
||||
*/
|
||||
async function run(
|
||||
id: string,
|
||||
statements: SqlStatement[],
|
||||
request: RequestId,
|
||||
maxRows = useDatabase.getState().maxRows,
|
||||
skip = 0,
|
||||
): Promise<DbStatementOutcome[]> {
|
||||
const wasConnected = useDatabase.getState().connected.has(id);
|
||||
const outcomes = await withConnection(id, () =>
|
||||
call(commands.dbExecute(request, id, statements, useDatabase.getState().maxRows)),
|
||||
);
|
||||
const outcomes = await withConnection(id, () => call(commands.dbExecute(request, id, statements, skip, maxRows)));
|
||||
if (!wasConnected) log("info", "Connected");
|
||||
for (const o of outcomes) {
|
||||
log("statement", o.statement.text, `${promptOf(id)}>`);
|
||||
@@ -791,12 +805,13 @@ export async function executeConsole(
|
||||
const request = nextRequestId();
|
||||
useDatabase.setState((s) => ({ running: { ...s.running, [path]: request } }));
|
||||
try {
|
||||
const outcomes = await run(id, statements, request);
|
||||
const pageSize = useDatabase.getState().maxRows;
|
||||
const outcomes = await run(id, statements, request, pageSize);
|
||||
const tabs: ResultTab[] = outcomes
|
||||
.filter((o) => o.result?.columns.length)
|
||||
.map((outcome) => {
|
||||
const n = nextTab++;
|
||||
return { id: n, title: tx`Result ${n}`, path, sourceId: id, outcome };
|
||||
return { id: n, title: tx`Result ${n}`, path, sourceId: id, outcome, page: 0, pageSize };
|
||||
});
|
||||
const failed = outcomes.some((o) => o.error);
|
||||
useDatabase.setState((s) => ({
|
||||
@@ -912,28 +927,90 @@ export async function openObjectDdl(id: string, schema: string, kind: DbObjectKi
|
||||
export async function rerunResult(tabId: number): Promise<void> {
|
||||
const tab = useDatabase.getState().results.find((r) => r.id === tabId);
|
||||
if (!tab || tab.busy) return;
|
||||
const patch = (p: Partial<ResultTab>) =>
|
||||
useDatabase.setState((s) => ({ results: s.results.map((r) => (r.id === tabId ? { ...r, ...p } : r)) }));
|
||||
patch({ busy: true });
|
||||
if (tab.plan) {
|
||||
patchResult(tabId, { busy: true });
|
||||
try {
|
||||
patch({ plan: await explain(tab.sourceId, tab.outcome.statement), busy: false });
|
||||
patchResult(tabId, { plan: await explain(tab.sourceId, tab.outcome.statement), busy: false });
|
||||
} catch (e) {
|
||||
patch({ busy: false });
|
||||
patchResult(tabId, { busy: false });
|
||||
if (!isCancelled(e)) log("error", `[${targetLabel(tab.sourceId)}] ${message(e)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await loadResultPage(tabId, tab.page ?? 0, tab.pageSize ?? useDatabase.getState().maxRows, true);
|
||||
}
|
||||
|
||||
/** Whether result tab `tab` can show other pages: running its query again only reads. */
|
||||
export function pageable(tab: ResultTab): boolean {
|
||||
return !tab.plan && isReadOnlyQuery(tab.outcome.statement.text, sourceById(tab.sourceId)?.driver ?? "sqlite");
|
||||
}
|
||||
|
||||
function patchResult(tabId: number, p: Partial<ResultTab>) {
|
||||
useDatabase.setState((s) => ({ results: s.results.map((r) => (r.id === tabId ? { ...r, ...p } : r)) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows page `page` of `pageSize` rows of result tab `tabId`: its query runs
|
||||
* again and the backend returns only that page (IDEA's result paging).
|
||||
* `rerun` also forgets the counted total.
|
||||
*/
|
||||
export async function loadResultPage(tabId: number, page: number, pageSize: number, rerun = false): Promise<void> {
|
||||
const tab = useDatabase.getState().results.find((r) => r.id === tabId);
|
||||
if (!tab || tab.busy || tab.plan) return;
|
||||
if (page > 0 && !pageable(tab)) return;
|
||||
patchResult(tabId, { busy: true });
|
||||
const { maxRows, skip } = pageFetch(page, pageSize);
|
||||
try {
|
||||
const [outcome] = await run(tab.sourceId, [tab.outcome.statement], nextRequestId());
|
||||
patch(outcome?.result ? { outcome, busy: false } : { busy: false });
|
||||
const [outcome] = await run(tab.sourceId, [tab.outcome.statement], nextRequestId(), maxRows, skip);
|
||||
const r = outcome?.result;
|
||||
if (r) {
|
||||
const sizeChanged = pageSize !== tab.pageSize;
|
||||
patchResult(tabId, {
|
||||
outcome,
|
||||
page,
|
||||
pageSize,
|
||||
busy: false,
|
||||
...(rerun || sizeChanged ? { total: null } : {}),
|
||||
});
|
||||
} else patchResult(tabId, { busy: false });
|
||||
if (outcome?.error) useDatabase.setState({ activeResult: OUTPUT_TAB });
|
||||
} catch (e) {
|
||||
patch({ busy: false });
|
||||
patchResult(tabId, { busy: false });
|
||||
if (!isCancelled(e)) log("error", message(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** `SELECT COUNT(*)` over a query, for queries that can be a subquery. */
|
||||
function countQuery(text: string): string | null {
|
||||
if (!/^\s*(\(|select\b|with\b|values\b|table\b)/i.test(text)) return null;
|
||||
// `)` on its own line: a trailing line comment (`… from t -- note`) would swallow it.
|
||||
return `select count(*) from (\n${text.trim()}\n) q`;
|
||||
}
|
||||
|
||||
/** Whether the rows of result tab `tab` can be counted. */
|
||||
export function countable(tab: ResultTab): boolean {
|
||||
return pageable(tab) && countQuery(tab.outcome.statement.text) !== null;
|
||||
}
|
||||
|
||||
/** Counts the rows of result tab `tabId`'s whole query (the pager's "of 501+"). */
|
||||
export async function countResult(tabId: number): Promise<number | null> {
|
||||
const tab = useDatabase.getState().results.find((r) => r.id === tabId);
|
||||
if (!tab || tab.busy || !countable(tab)) return null;
|
||||
const text = countQuery(tab.outcome.statement.text)!;
|
||||
patchResult(tabId, { busy: true });
|
||||
try {
|
||||
const [outcome] = await run(tab.sourceId, [{ from: 0, to: text.length, text }], nextRequestId(), 1);
|
||||
const total = Number(outcome?.result?.rows[0]?.[0]);
|
||||
const known = outcome?.error || !Number.isFinite(total) ? null : total;
|
||||
patchResult(tabId, { busy: false, ...(known === null ? {} : { total: known }) });
|
||||
return known;
|
||||
} catch (e) {
|
||||
patchResult(tabId, { busy: false });
|
||||
if (!isCancelled(e)) log("error", message(e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Statements that only read (they leave a manual transaction clean). */
|
||||
const READS = /^\s*(select|show|pragma|explain|describe|desc|values|table)\b/i;
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача