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

+56 -11
Переглянути файл
@@ -150,6 +150,7 @@ impl ClickhousePool {
self.0.client.read().clone().ok_or_else(|| IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"))
}
/// `max_rows`: the rows the server may send (the skipped ones included).
fn request(&self, client: &Client, query_id: &str, max_rows: usize, initialized: bool) -> RequestBuilder {
let mut request = client
.post(self.0.endpoint.clone())
@@ -177,6 +178,17 @@ impl ClickhousePool {
}
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
self.execute_from(sql, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without parsing or keeping them.
pub async fn execute_from(
&self,
sql: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
if transaction_statement(sql) {
return Err(transaction_error());
}
@@ -188,7 +200,7 @@ impl ClickhousePool {
};
let client = self.client()?;
let query_id = uuid::Uuid::new_v4().to_string();
let request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned());
let request = self.request(&client, &query_id, skip.saturating_add(max_rows), *session).body(sql.to_owned());
let started = Instant::now();
let fetch = async {
let mut response = request.send().await.map_err(http_error)?;
@@ -216,7 +228,7 @@ impl ClickhousePool {
}
(Vec::new(), Vec::new(), false)
} else {
let mut reader = RowReader::new(max_rows);
let mut reader = RowReader::new(skip, max_rows);
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
reader.push(&chunk)?;
}
@@ -309,11 +321,13 @@ impl ClickhousePool {
type DecodedResult = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, bool);
/// Decodes a streamed [`FORMAT`] body. Keeps the first `max_rows` rows; later rows only set
/// `has_more` and are read past unparsed, so a grid limit bounds memory however large the
/// block `result_overflow_mode=break` lets the server send. A line that is not a JSON array
/// Decodes a streamed [`FORMAT`] body. Reads past the first `skip` rows unparsed (a later
/// page), then keeps `max_rows` rows; later rows only set `has_more` and are read past
/// unparsed, so a grid limit bounds memory however large the block
/// `result_overflow_mode=break` lets the server send. A line that is not a JSON array
/// starts the exception ClickHouse writes when a query fails mid-stream.
struct RowReader {
skip: usize,
max_rows: usize,
pending: Vec<u8>,
names: Option<Vec<String>>,
@@ -324,8 +338,17 @@ struct RowReader {
}
impl RowReader {
fn new(max_rows: usize) -> Self {
Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None }
fn new(skip: usize, max_rows: usize) -> Self {
Self {
skip,
max_rows,
pending: Vec::new(),
names: None,
types: None,
rows: Vec::new(),
more: false,
exception: None,
}
}
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
@@ -356,9 +379,15 @@ impl RowReader {
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
return Ok(());
}
if self.names.is_some() && self.types.is_some() && self.rows.len() >= self.max_rows {
self.more = true;
return Ok(());
if self.names.is_some() && self.types.is_some() {
if self.skip > 0 {
self.skip -= 1;
return Ok(());
}
if self.rows.len() >= self.max_rows {
self.more = true;
return Ok(());
}
}
let Ok(values) = serde_json::from_slice::<Vec<Box<serde_json::value::RawValue>>>(text) else {
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
@@ -547,7 +576,11 @@ mod tests {
}
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
let mut reader = RowReader::new(max_rows);
decode_from(body, 0, max_rows, chunk)
}
fn decode_from(body: &[u8], skip: usize, max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
let mut reader = RowReader::new(skip, max_rows);
for part in body.chunks(chunk) {
reader.push(part)?;
}
@@ -586,6 +619,18 @@ mod tests {
assert!(decode(b"[\"x\"]\n[\"UInt8\"]\n[1, 2]\n", 1, 64).is_err());
}
#[test]
fn a_later_page_skips_rows_without_keeping_them() {
let body = b"[\"n\"]\n[\"UInt8\"]\n[1]\n[2]\n[3]\n[4]\n[5]\n";
let page = |skip, max| {
let (_, rows, more) = decode_from(body, skip, max, 3).unwrap();
(rows.into_iter().map(|r| r[0].clone().unwrap()).collect::<Vec<_>>(), more)
};
assert_eq!(page(2, 2), (vec!["3".to_owned(), "4".to_owned()], true));
assert_eq!(page(4, 2), (vec!["5".to_owned()], false));
assert_eq!(page(9, 2), (Vec::<String>::new(), false));
}
#[test]
fn rows_past_the_limit_are_skipped_but_a_later_exception_still_fails() {
let mut body = String::from("[\"n\"]\n[\"UInt64\"]\n");
+49 -10
Переглянути файл
@@ -337,10 +337,13 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
.collect()
}
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
/// with their cells read as `$mode`.
/// Streams the results of `$sql` on executor `$exec`, reading past the first `$skip`
/// rows without decoding them, then keeping at most `$max` rows with their cells read as `$mode`.
macro_rules! fetch {
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {
fetch!($exec, $sql, 0usize, $max, $mode)
};
($exec:expr, $sql:expr, $skip:expr, $max:expr, $mode:expr) => {{
use futures::StreamExt;
let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec);
let mut columns: Vec<ResultColumn> = Vec::new();
@@ -349,6 +352,7 @@ macro_rules! fetch {
let mut has_more = false;
let mut failure = None;
let mut first: Option<Instant> = None;
let mut to_skip: usize = $skip;
while let Some(item) = stream.next().await {
first.get_or_insert_with(Instant::now);
match item {
@@ -357,6 +361,10 @@ macro_rules! fetch {
if columns.is_empty() {
columns = result_columns(&row);
}
if to_skip > 0 {
to_skip -= 1;
continue;
}
if rows.len() >= $max {
has_more = true;
break;
@@ -386,7 +394,8 @@ async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
Ok(())
}
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult<Fetched> {
/// Like [`fetch!`] for SQL Server: the first `skip` rows are read past without decoding.
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, skip: usize, max: usize) -> IdeResult<Fetched> {
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
let first = Some(Instant::now());
let columns = stream
@@ -399,8 +408,13 @@ async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult
.collect();
let mut rows = Vec::new();
let mut has_more = false;
let mut to_skip = skip;
while let Some(item) = stream.next().await {
if let tiberius::QueryItem::Row(row) = item.map_err(mssql_err)? {
if to_skip > 0 {
to_skip -= 1;
continue;
}
if rows.len() >= max {
has_more = true;
break;
@@ -734,7 +748,7 @@ impl Pool {
let fetched: IdeResult<Fetched> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
mssql_fetch(&mut conn, sql, max).await
mssql_fetch(&mut conn, sql, 0, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
@@ -744,11 +758,23 @@ impl Pool {
/// Runs one statement and keeps at most `max_rows` rows. Cancelling stops
/// the statement on the server (PostgreSQL, MySQL) and drops the connection.
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
self.execute_from(sql, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
/// (a later page of a result: only that page is decoded and returned).
pub async fn execute_from(
&self,
sql: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
if let Pool::Clickhouse(p) = self {
return p.execute(sql, max_rows, cancel).await;
return p.execute_from(sql, skip, max_rows, cancel).await;
}
let mut conn = Held::acquire(self).await?;
let r = conn.execute(self, sql, max_rows, cancel).await;
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
@@ -874,9 +900,22 @@ impl Held {
sql: &str,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
self.execute_from(pool, sql, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
/// (see [`Pool::execute_from`]).
pub async fn execute_from(
&mut self,
pool: &Pool,
sql: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
if let Held::Clickhouse(c) = self {
return c.execute(sql, max_rows, cancel).await;
return c.execute_from(sql, skip, max_rows, cancel).await;
}
let started = Instant::now();
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
@@ -887,7 +926,7 @@ impl Held {
None => None,
};
let r = tokio::select! {
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
r = async { fetch!(&mut **$conn, sql, skip, max_rows, Cells::Display) } => r,
() = cancel.cancelled() => Err(IdeError::cancelled()),
};
if cancel.is_cancelled() {
@@ -916,7 +955,7 @@ impl Held {
Held::SqlServer(c) => {
// Tiberius has no attention/cancel API; stop reading and discard the connection.
tokio::select! {
r = mssql_fetch(c, sql, max_rows) => r?,
r = mssql_fetch(c, sql, skip, max_rows) => r?,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
}
}
+18 -4
Переглянути файл
@@ -1009,7 +1009,20 @@ impl DbManager {
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let r = self.run(id, statement, max_rows, cancel).await;
self.execute_from(id, statement, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping
/// them: a later page of a result, where only that page is decoded and returned.
pub async fn execute_from(
&self,
id: &str,
statement: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let r = self.run(id, statement, skip, max_rows, cancel).await;
let ran = match &r {
Ok(_) => true,
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
@@ -1044,7 +1057,7 @@ impl DbManager {
/// The execution plan of `statement` (explained, not run).
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
let driver = self.pool(id).await?.driver();
let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?;
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
plan::parse(driver, &result)
}
@@ -1052,13 +1065,14 @@ impl DbManager {
&self,
id: &str,
statement: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let pool = self.pool(id).await?;
let session = self.sessions.lock().get(id).cloned();
let Some(session) = session else {
return pool.execute(statement, max_rows, cancel).await;
return pool.execute_from(statement, skip, max_rows, cancel).await;
};
if pool.driver() == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
@@ -1070,7 +1084,7 @@ impl DbManager {
*held = Some(conn);
}
let conn = held.as_mut().expect("opened above");
let r = conn.execute(&pool, statement, max_rows, cancel).await;
let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
// The server rolls the transaction back with the connection.
held.take().expect("opened above").discard().await;
+25 -1
Переглянути файл
@@ -1,7 +1,9 @@
//! Introspection, queries and table edits against a real SQLite file.
use ide_core::{CancellationToken, IdeResult};
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey};
use ide_db::{
CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, QueryResult, SortKey,
};
async fn setup() -> (tempfile::TempDir, DbManager) {
let tmp = tempfile::tempdir().unwrap();
@@ -123,6 +125,28 @@ async fn queries_with_limits_nulls_and_blobs() -> IdeResult<()> {
Ok(())
}
#[tokio::test]
async fn a_later_page_returns_only_its_rows() -> IdeResult<()> {
let (_tmp, db) = setup().await;
let c = CancellationToken::new();
let sql = "SELECT id FROM users ORDER BY id";
let ids = |r: &QueryResult| r.rows.iter().map(|row| row[0].clone().unwrap()).collect::<Vec<_>>();
let second = db.execute_from("s1", sql, 1, 1, &c).await?;
assert_eq!(ids(&second), vec!["2"]);
assert!(second.has_more);
assert_eq!(second.columns.len(), 1);
let last = db.execute_from("s1", sql, 2, 5, &c).await?;
assert_eq!(ids(&last), vec!["3"]);
assert!(!last.has_more);
let past = db.execute_from("s1", sql, 9, 5, &c).await?;
assert!(past.rows.is_empty() && !past.has_more);
assert_eq!(past.columns.len(), 1, "columns of a page past the end come from the prepared statement");
Ok(())
}
#[tokio::test]
async fn pages_sorts_filters_and_edits_in_a_transaction() -> IdeResult<()> {
let (_tmp, db) = setup().await;
+7 -4
Переглянути файл
@@ -123,13 +123,14 @@ pub(crate) fn register(d: &mut Dispatcher) {
statements(&c.host, &c.window, &id, &text, from, to)
});
d.add("db_execute", |c| async move {
let (request, id, statements, max_rows) = (
let (request, id, statements, skip, max_rows) = (
c.arg::<RequestId>("request")?,
c.arg::<String>("id")?,
c.arg::<Vec<SqlStatement>>("statements")?,
c.arg::<Option<u32>>("skip")?.unwrap_or(0),
c.arg::<u32>("maxRows")?,
);
execute(&c.host, &c.window, request, &id, statements, max_rows).await
execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await
});
d.add("db_table_page", |c| async move {
let (request, id, schema, table) = (
@@ -325,20 +326,22 @@ pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to
}
/// Runs `statements` in order on data source `id`, stopping at the first
/// error. Cancel with `cancel_request(request)`.
/// error. Each result reads past its first `skip` rows (a later page) and keeps
/// `max_rows`. Cancel with `cancel_request(request)`.
pub async fn execute(
host: &Host,
window: &str,
request: RequestId,
id: &str,
statements: Vec<SqlStatement>,
skip: u32,
max_rows: u32,
) -> IdeResult<Vec<StatementOutcome>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let mut out = Vec::new();
for statement in statements {
match db.execute(id, &statement.text, max_rows.max(1) as usize, &cancel).await {
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await {
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
host.end_request(request);
+2 -1
Переглянути файл
@@ -250,9 +250,10 @@ pub async fn db_execute(
request: RequestId,
id: String,
statements: Vec<SqlStatement>,
skip: u32,
max_rows: u32,
) -> IdeResult<Vec<StatementOutcome>> {
ide_host::ops::db::execute(&state.host, window.label(), request, &id, statements, max_rows).await
ide_host::ops::db::execute(&state.host, window.label(), request, &id, statements, skip, max_rows).await
}
#[tauri::command]
+1 -1
Переглянути файл
@@ -1233,7 +1233,7 @@ export const commands = {
dbSetManualCommit: (id: string, manual: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("db_set_manual_commit", { id, manual })),
dbFinishTransaction: (id: string, commit: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("db_finish_transaction", { id, commit })),
dbStatements: (id: string, text: string, from: number, to: number) => typedError<SqlStatement[], IdeError>(__TAURI_INVOKE("db_statements", { id, text, from, to })),
dbExecute: (request: RequestId, id: string, statements: SqlStatement[], maxRows: number) => typedError<DbStatementOutcome[], IdeError>(__TAURI_INVOKE("db_execute", { request, id, statements, maxRows })),
dbExecute: (request: RequestId, id: string, statements: SqlStatement[], skip: number, maxRows: number) => typedError<DbStatementOutcome[], IdeError>(__TAURI_INVOKE("db_execute", { request, id, statements, skip, maxRows })),
dbTablePage: (request: RequestId, id: string, schema: string, table: string, filter: string | null, sort: DbSortKey[], offset: number, limit: number) => typedError<DbTablePage, IdeError>(__TAURI_INVOKE("db_table_page", { request, id, schema, table, filter, sort, offset, limit })),
dbApplyEdits: (id: string, schema: string, table: string, edits: DbRowEdit[]) => typedError<number | null, IdeError>(__TAURI_INVOKE("db_apply_edits", { id, schema, table, edits })),
dbConsolePath: (id: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("db_console_path", { id })),
+201 -28
Переглянути файл
@@ -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;
+74 -15
Переглянути файл
@@ -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;
}
+59
Переглянути файл
@@ -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);
});
});
+22
Переглянути файл
@@ -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,
)}
+99 -5
Переглянути файл
@@ -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>
+11
Переглянути файл
@@ -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]!)}
+82
Переглянути файл
@@ -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 });
});
});
+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 };
}
+28
Переглянути файл
@@ -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);
});
});
+15
Переглянути файл
@@ -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));
}
+92 -15
Переглянути файл
@@ -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;
+9 -1
Переглянути файл
@@ -6190,5 +6190,13 @@
"Plugin update available": "有插件可更新",
"{length} plugin updates available": "{length} 个插件可更新",
"Updated {updated} plugins": "已更新 {updated} 个插件",
"This version is signed with a different key than the installed one, and the publisher's earlier key doesn't vouch for it. Only continue if you know the publisher changed keys.": "此版本的签名密钥与已安装的版本不同,且发布者之前的密钥没有为它背书。只有在确认发布者更换了密钥时才继续。"
"This version is signed with a different key than the installed one, and the publisher's earlier key doesn't vouch for it. Only continue if you know the publisher changed keys.": "此版本的签名密钥与已安装的版本不同,且发布者之前的密钥没有为它背书。只有在确认发布者更换了密钥时才继续。",
"Search in results": "在结果中搜索",
"Bad regex": "正则表达式无效",
"Filter rows": "筛选行",
"Result pages": "结果分页",
"First Page": "第一页",
"Count rows": "统计行数",
"Last Page": "最后一页",
"{first}-{last} of {total}": "{first}-{last},共 {total}"
}
+2
Переглянути файл
@@ -225,6 +225,8 @@ const ICONS = {
refresh: "general/refresh",
settings: "general/settings",
help: "openidea/general/help",
"page-first": "openidea/general/pageFirst",
"page-last": "openidea/general/pageLast",
sliders: "openidea/general/sliders",
"graphql-variables": "openidea/general/graphqlVariables",
layout: "general/layout",
+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- OpenIDEA: First Page (bar and chevron left) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 4.5V11.5M11 11.5L7.5 8L11 4.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 267 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- OpenIDEA: Last Page (chevron right and bar) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 4.5V11.5M5 11.5L8.5 8L5 4.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 266 B