дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(database): editable result grid for single-table query results (#1854)
Closes #1834. A console result grid is editable in place when its rows trace back to one base table: a plain SELECT of column references / * from one table (schema-qualified or aliased, resolved through the console's active schema) whose primary key columns are all in the result. Edits, added and deleted rows reuse the table editor's workflow and are applied in one transaction, then the query re-runs. Aliased or computed columns stay read-only; joins, aggregates, subqueries/CTEs, unions and ambiguous column provenance show a read-only banner with the reason. Co-authored-by: Inf1mum <[email protected]>
Цей коміт міститься в:
1 джерело
4d088c35ff
коміт
1b889a5147
7 files changed
+1219
-41
No files matched your search
@@ -6,27 +6,41 @@
|
||||
import { copySelectionOnKeyDown } from "@/ui/selectableText";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { DbPlanNode } from "@/platform/ipc";
|
||||
import { call, commands, type DbPlanNode, type DbQueryResult } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { EmptyState, Icon, IconButton, Select, Spinner, Tabs, Tree, showContextMenu, T } from "@/ui";
|
||||
import { Button, EmptyState, Icon, IconButton, Select, Spinner, Tabs, Tree, showContextMenu, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { ChartView } from "./ChartView";
|
||||
import { DataView, ExtractorSelect, PanelToggle, copyFormatOf, exportMenu, type DataPanel } from "./DataView";
|
||||
import type { ExportData, ExportFormat } from "./export";
|
||||
import { buildRowEdits, emptyEdits, hasEdits, splitTarget, type Cell, type PendingEdits } from "./model";
|
||||
import {
|
||||
editStatements,
|
||||
pkResultColumns,
|
||||
resultColumnSources,
|
||||
resultEditability,
|
||||
resolveTarget,
|
||||
type EditBlockReason,
|
||||
} from "./resultEditing";
|
||||
import { pageWindow } from "./resultView";
|
||||
import { analyze } from "./sql/analyze";
|
||||
import { nameMatcher } from "./sql/dialect";
|
||||
import {
|
||||
OUTPUT_TAB,
|
||||
clearOutput,
|
||||
closeResult,
|
||||
countResult,
|
||||
countable,
|
||||
detailsKey,
|
||||
loadDetails,
|
||||
loadResultPage,
|
||||
log,
|
||||
outcomeSummary,
|
||||
pageable,
|
||||
rerunResult,
|
||||
sourceById,
|
||||
targetLabel,
|
||||
useDatabase,
|
||||
withConnection,
|
||||
type OutputLine,
|
||||
type ResultTab,
|
||||
} from "./store";
|
||||
@@ -80,6 +94,20 @@ function queryTable(text: string, data: Pick<ExportData, "driver">): string {
|
||||
|
||||
const PAGE_SIZES = [10, 100, 500, 1000];
|
||||
|
||||
/** Why a result's grid is read-only, as the issue's examples put it. */
|
||||
function readonlyNotice(reason: EditBlockReason): string {
|
||||
switch (reason) {
|
||||
case "joined":
|
||||
return tx("Read-only: the query joins several tables.");
|
||||
case "aggregated":
|
||||
return tx("Read-only: the result is aggregated (DISTINCT, GROUP BY or an aggregate function).");
|
||||
case "setOp":
|
||||
return tx("Read-only: the result combines several queries (UNION / INTERSECT / EXCEPT).");
|
||||
case "plain":
|
||||
return tx("Read-only: the result is not a plain select from a single table.");
|
||||
}
|
||||
}
|
||||
|
||||
/** 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!;
|
||||
@@ -163,22 +191,170 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
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]);
|
||||
const value = useCallback((row: number, col: number) => r?.rows[row]?.[col] ?? null, [r]);
|
||||
const source = useDatabase((s) => s.sources.find((source) => source.id === splitTarget(tab.sourceId).id));
|
||||
const driver = source?.driver ?? "sqlite";
|
||||
|
||||
// Editing a result in place (like DataGrip): only a plain select of one base
|
||||
// table, carrying the table's primary key, on a writable data source.
|
||||
const text = tab.outcome.statement.text;
|
||||
const editability = useMemo(() => resultEditability(text, driver), [text, driver]);
|
||||
const model = useDatabase((s) => s.completion[tab.sourceId]) ?? null;
|
||||
const target = useMemo(
|
||||
() => (editability.kind === "editable" ? resolveTarget(editability, model, tab.schema, driver) : null),
|
||||
[editability, model, tab.schema, driver],
|
||||
);
|
||||
const detailsId = target ? detailsKey(tab.sourceId, target.schema ?? "", target.table) : null;
|
||||
const details = useDatabase((s) => (detailsId ? s.details[detailsId] : undefined));
|
||||
const detailsValue = details?.status === "ok" ? details.value : null;
|
||||
useEffect(() => {
|
||||
// Only when never loaded: a failed fetch must not retry on every state change.
|
||||
if (target && !details) void loadDetails(tab.sourceId, target.schema ?? "", target.table);
|
||||
}, [target, details, tab.sourceId]);
|
||||
const resultNames = useMemo(() => r?.columns.map((c) => c.name) ?? [], [r]);
|
||||
const eq = useMemo(
|
||||
() =>
|
||||
driver === "postgres" || driver === "oracle" || driver === "sqlServer"
|
||||
? (a: string | null | undefined, b: string | null | undefined) => !!a && a === b
|
||||
: nameMatcher(driver),
|
||||
[driver],
|
||||
);
|
||||
const sources = useMemo(
|
||||
() =>
|
||||
editability.kind === "editable"
|
||||
? resultColumnSources(editability.items, resultNames, detailsValue?.columns.map((c) => c.name) ?? null, eq)
|
||||
: [],
|
||||
[editability, resultNames, detailsValue, eq],
|
||||
);
|
||||
const pk = useMemo(() => {
|
||||
if (!detailsValue) return null;
|
||||
return pkResultColumns(
|
||||
detailsValue.columns.filter((c) => c.primaryKey).map((c) => c.name),
|
||||
sources,
|
||||
resultNames,
|
||||
eq,
|
||||
);
|
||||
}, [detailsValue, sources, resultNames, eq]);
|
||||
const readOnlySource = source?.readOnly ?? false;
|
||||
const editable =
|
||||
editability.kind === "editable" &&
|
||||
!!target &&
|
||||
!!pk &&
|
||||
!readOnlySource &&
|
||||
driver !== "clickhouse" &&
|
||||
sources.some((s) => s !== null);
|
||||
|
||||
// The pending edits belong to the result they were made on: fresh rows (a
|
||||
// re-run, another page) start over, without an effect.
|
||||
const [pending, setPending] = useState<{ outcome: DbQueryResult | null; edits: PendingEdits }>({
|
||||
outcome: null,
|
||||
edits: emptyEdits(),
|
||||
});
|
||||
const edits = pending.outcome === r ? pending.edits : emptyEdits();
|
||||
const setEdits = (f: (e: PendingEdits) => PendingEdits) =>
|
||||
setPending((previous) => ({ outcome: r, edits: f(previous.outcome === r ? previous.edits : emptyEdits()) }));
|
||||
const loaded = r?.rows.length ?? 0;
|
||||
const rowCount = loaded + edits.inserts.length;
|
||||
const value = useCallback(
|
||||
(row: number, col: number): Cell => {
|
||||
const name = resultNames[col]!;
|
||||
if (row >= loaded) return edits.inserts[row - loaded]?.get(name) ?? null;
|
||||
const upd = edits.updates.get(row);
|
||||
if (upd?.has(name)) return upd.get(name)!;
|
||||
return r?.rows[row]?.[col] ?? null;
|
||||
},
|
||||
[r, resultNames, loaded, edits],
|
||||
);
|
||||
|
||||
const onEdit = (row: number, col: number, v: Cell) => {
|
||||
if (!editable || busy || !sources[col]) return; // an aliased or computed column
|
||||
const name = resultNames[col]!;
|
||||
setEdits((e) => {
|
||||
const next: PendingEdits = { updates: new Map(e.updates), deletes: new Set(e.deletes), inserts: [...e.inserts] };
|
||||
if (row >= loaded) {
|
||||
const ins = new Map(next.inserts[row - loaded]);
|
||||
ins.set(name, v);
|
||||
next.inserts[row - loaded] = ins;
|
||||
} else {
|
||||
const upd = new Map(next.updates.get(row));
|
||||
if ((r?.rows[row]?.[col] ?? null) === v) upd.delete(name);
|
||||
else upd.set(name, v);
|
||||
if (upd.size) next.updates.set(row, upd);
|
||||
else next.updates.delete(row);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteRows = (rows: number[]) =>
|
||||
setEdits((e) => {
|
||||
const deletes = new Set(e.deletes);
|
||||
const insertsGone = new Set(rows.filter((row) => row >= loaded).map((row) => row - loaded));
|
||||
for (const row of rows) if (row < loaded) deletes.add(row);
|
||||
return { ...e, deletes, inserts: e.inserts.filter((_, i) => !insertsGone.has(i)) };
|
||||
});
|
||||
|
||||
const dirty = hasEdits(edits);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const submitGuard = useRef(false);
|
||||
const busy = submitting || !!tab.busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!r || !target || !pk || !editable || busy || submitGuard.current) return;
|
||||
submitGuard.current = true;
|
||||
setSubmitting(true);
|
||||
const list = buildRowEdits(resultNames, r.rows, pk, edits);
|
||||
try {
|
||||
// The Output log shows what the submit is about to change.
|
||||
for (const s of editStatements(driver, target.schema ?? "", target.table, list)) log("statement", s);
|
||||
const n = await withConnection(tab.sourceId, () =>
|
||||
call(commands.dbApplyEdits(tab.sourceId, target.schema ?? "", target.table, list)),
|
||||
);
|
||||
log("result", `${target.table}: ${n} row(s) changed`);
|
||||
notifications().notify({ level: "info", title: `${target.table}: ${n} row(s) changed`, key: "db-submit" });
|
||||
setEdits(() => emptyEdits());
|
||||
await rerunResult(tab.id);
|
||||
} catch (e) {
|
||||
log("error", e instanceof Error ? e.message : String(e));
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Changes were not submitted",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
key: "db-submit",
|
||||
});
|
||||
} finally {
|
||||
submitGuard.current = false;
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!r) return null;
|
||||
const driver = sourceById(tab.sourceId)?.driver ?? "sqlite";
|
||||
const data = (): ExportData => ({
|
||||
columns,
|
||||
rows: r.rows,
|
||||
driver,
|
||||
table: queryTable(tab.outcome.statement.text, { driver }),
|
||||
table: queryTable(text, { driver }),
|
||||
});
|
||||
const tableLabel = target ? `${target.schema ? `${target.schema}.` : ""}${target.table}` : "";
|
||||
const tablePkCount = detailsValue?.columns.filter((c) => c.primaryKey).length ?? 0;
|
||||
let notice: string | null = null;
|
||||
if (!editable) {
|
||||
if (driver === "clickhouse") notice = tx("Use the query console to change ClickHouse data.");
|
||||
else if (readOnlySource) notice = tx("Read-only data source.");
|
||||
else if (editability.kind === "readonly") notice = readonlyNotice(editability.reason);
|
||||
else if (!target) notice = tx("Read-only: the table schema could not be identified in the result database.");
|
||||
else if (details?.status === "error" || (detailsValue && tablePkCount > 0))
|
||||
// The result exists but doesn't carry every key column (or the keys are unknown).
|
||||
notice = tx`Read-only: the result has no primary key of ${tableLabel}.`;
|
||||
else if (detailsValue) notice = tx("Read-only: the table has no primary key to identify rows.");
|
||||
// else: the table's details are still loading.
|
||||
}
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<div className={styles.toolbar}>
|
||||
<IconButton icon="db-table" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
|
||||
<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)} />
|
||||
<IconButton icon="refresh" title="Rerun Query" disabled={busy} onClick={() => void rerunResult(tab.id)} />
|
||||
{view === "table" && (
|
||||
<IconButton
|
||||
icon="search"
|
||||
@@ -188,6 +364,22 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
onClick={() => setSearchOpen(!searchOpen)}
|
||||
/>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
title="Add Row"
|
||||
disabled={busy}
|
||||
onClick={() => setEdits((e) => ({ ...e, inserts: [...e.inserts, new Map()] }))}
|
||||
/>
|
||||
<Button size="small" variant="primary" disabled={!dirty || busy} onClick={() => void submit()}>
|
||||
<T>Submit</T>
|
||||
</Button>
|
||||
<Button size="small" disabled={!dirty || busy} onClick={() => setEdits(() => emptyEdits())}>
|
||||
<T>Revert</T>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{tab.busy && <Spinner size={12} />}
|
||||
<span className={styles.spacer} />
|
||||
<span className={styles.resultSource} title={tab.outcome.statement.text}>
|
||||
@@ -197,9 +389,7 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
<IconButton
|
||||
icon="export"
|
||||
title="Export Data"
|
||||
onClick={(e) =>
|
||||
showContextMenu(e, exportMenu(data(), queryTable(tab.outcome.statement.text, { driver }), extractor))
|
||||
}
|
||||
onClick={(e) => showContextMenu(e, exportMenu(data(), queryTable(text, { driver }), extractor))}
|
||||
/>
|
||||
{view === "table" && <PanelToggle panel={panel} onChange={setPanel} />}
|
||||
</div>
|
||||
@@ -208,21 +398,48 @@ function Result({ tab }: { tab: ResultTab }) {
|
||||
<ChartView columns={columns} rows={r.rows} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={clsx(styles.fill, styles.gridHost)}>
|
||||
<DataView
|
||||
columns={columns}
|
||||
rowCount={r.rows.length}
|
||||
value={value}
|
||||
panel={panel}
|
||||
onPanelChange={setPanel}
|
||||
copyFormat={copyFormatOf(extractor)}
|
||||
searchOpen={searchOpen}
|
||||
onSearchOpenChange={setSearchOpen}
|
||||
firstRowNumber={(tab.page ?? 0) * (tab.pageSize ?? 0) + 1}
|
||||
aria-label={tab.title}
|
||||
/>
|
||||
<Pager tab={tab} />
|
||||
</div>
|
||||
<>
|
||||
{notice && (
|
||||
<div className={styles.notice} role="note">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
<div className={clsx(styles.fill, styles.gridHost)}>
|
||||
<DataView
|
||||
columns={columns.map((c) => ({ ...c, primaryKey: !!pk?.includes(c.name) }))}
|
||||
rowCount={rowCount}
|
||||
fixedRows={loaded}
|
||||
value={value}
|
||||
panel={panel}
|
||||
onPanelChange={setPanel}
|
||||
copyFormat={copyFormatOf(extractor)}
|
||||
searchOpen={searchOpen}
|
||||
onSearchOpenChange={setSearchOpen}
|
||||
firstRowNumber={(tab.page ?? 0) * (tab.pageSize ?? 0) + 1}
|
||||
aria-label={tab.title}
|
||||
rowState={(row) => (row >= loaded ? "inserted" : edits.deletes.has(row) ? "deleted" : null)}
|
||||
isModified={(row, col) => !!edits.updates.get(row)?.has(resultNames[col]!)}
|
||||
editable={editable && !busy}
|
||||
onEdit={onEdit}
|
||||
rowMenu={(rows) =>
|
||||
editable && !busy
|
||||
? [
|
||||
{ label: "Delete Rows", icon: "trash", run: () => deleteRows(rows) },
|
||||
{
|
||||
label: "Restore Rows",
|
||||
run: () =>
|
||||
setEdits((e) => ({
|
||||
...e,
|
||||
deletes: new Set([...e.deletes].filter((d) => !rows.includes(d))),
|
||||
})),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<Pager tab={busy ? { ...tab, busy: true } : tab} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DbCompletion, DbDriver, DbRowEdit } from "@/platform/ipc";
|
||||
import {
|
||||
editStatements,
|
||||
pkResultColumns,
|
||||
resolveTarget,
|
||||
resultColumnSources,
|
||||
resultEditability,
|
||||
} from "./resultEditing";
|
||||
|
||||
const editable = (text: string, driver: DbDriver = "postgres") => {
|
||||
const e = resultEditability(text, driver);
|
||||
expect(e.kind, JSON.stringify(e)).toBe("editable");
|
||||
return e as Extract<ReturnType<typeof resultEditability>, { kind: "editable" }>;
|
||||
};
|
||||
|
||||
const readonly = (text: string, driver: DbDriver = "postgres") => {
|
||||
const e = resultEditability(text, driver);
|
||||
expect(e.kind, JSON.stringify(e)).toBe("readonly");
|
||||
return (e as Extract<ReturnType<typeof resultEditability>, { kind: "readonly" }>).reason;
|
||||
};
|
||||
|
||||
const eq = (a: string | null | undefined, b: string | null | undefined) =>
|
||||
!!a && !!b && a.toLowerCase() === b.toLowerCase();
|
||||
|
||||
describe("resultEditability", () => {
|
||||
it("accepts a plain select of one table", () => {
|
||||
const e = editable("SELECT * FROM users WHERE id < 10 ORDER BY id LIMIT 5");
|
||||
expect(e.schema).toBeNull();
|
||||
expect(e.table).toBe("users");
|
||||
expect(e.alias).toBeNull();
|
||||
expect(e.items).toEqual([{ kind: "star", qualifier: null }]);
|
||||
});
|
||||
|
||||
it("accepts a schema-qualified table, aliases and plain column references", () => {
|
||||
const e = editable("select u.id, u.name\nfrom public.users as u\nwhere u.id > 1\norder by u.id");
|
||||
expect(e.schema).toBe("public");
|
||||
expect(e.table).toBe("users");
|
||||
expect(e.alias).toBe("u");
|
||||
expect(e.items).toEqual([
|
||||
{ kind: "column", qualifier: "u", name: "id" },
|
||||
{ kind: "column", qualifier: "u", name: "name" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts qualified stars and FOR UPDATE", () => {
|
||||
expect(editable("SELECT u.* FROM users u FOR UPDATE").items).toEqual([{ kind: "star", qualifier: "u" }]);
|
||||
expect(editable("SELECT id FROM users").items).toEqual([{ kind: "column", qualifier: null, name: "id" }]);
|
||||
});
|
||||
|
||||
it("separates an implicit alias from a schema-qualified table", () => {
|
||||
expect(editable("SELECT u.id, u.name FROM public.users u")).toMatchObject({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
alias: "u",
|
||||
});
|
||||
expect(readonly("SELECT a.id FROM s1.users a JOIN s2.users b USING (id)")).toBe("joined");
|
||||
expect(readonly("SELECT a.id FROM s1.users a, s2.users b")).toBe("joined");
|
||||
});
|
||||
|
||||
it("keeps a quoted identifier a column reference", () => {
|
||||
expect(editable('SELECT "name", "user id" FROM "my table"', "sqlite").items).toEqual([
|
||||
{ kind: "column", qualifier: null, name: "name" },
|
||||
{ kind: "column", qualifier: null, name: "user id" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects joins, comma lists and derived tables as joined / plain", () => {
|
||||
expect(readonly("SELECT * FROM a JOIN b ON a.id = b.id")).toBe("joined");
|
||||
expect(readonly("SELECT * FROM a LEFT OUTER JOIN b ON a.id = b.id")).toBe("joined");
|
||||
expect(readonly("SELECT * FROM a, b WHERE a.id = b.id")).toBe("joined");
|
||||
expect(readonly("SELECT * FROM a CROSS JOIN b")).toBe("joined");
|
||||
expect(readonly("SELECT * FROM (SELECT id FROM a) x")).toBe("plain");
|
||||
});
|
||||
|
||||
it("rejects aggregates, DISTINCT, GROUP BY, HAVING and windows as aggregated", () => {
|
||||
expect(readonly("SELECT count(*) FROM users")).toBe("aggregated");
|
||||
expect(readonly("SELECT max(id) + 1 FROM users")).toBe("aggregated");
|
||||
expect(readonly("SELECT DISTINCT name FROM users")).toBe("aggregated");
|
||||
expect(readonly("SELECT name FROM users GROUP BY name")).toBe("aggregated");
|
||||
expect(readonly("SELECT name FROM users GROUP BY name HAVING count(*) > 1")).toBe("aggregated");
|
||||
expect(readonly("SELECT name, row_number() OVER () FROM users")).toBe("aggregated");
|
||||
// A column called `min` (no call parens) is not an aggregate.
|
||||
expect(editable("SELECT min FROM mins").items).toEqual([{ kind: "column", qualifier: null, name: "min" }]);
|
||||
});
|
||||
|
||||
it("rejects set operations", () => {
|
||||
expect(readonly("SELECT id FROM a UNION SELECT id FROM b")).toBe("setOp");
|
||||
expect(readonly("SELECT id FROM a INTERSECT SELECT id FROM b")).toBe("setOp");
|
||||
expect(readonly("SELECT id FROM a EXCEPT SELECT id FROM b")).toBe("setOp");
|
||||
});
|
||||
|
||||
it("rejects anything that is not a plain single-table select", () => {
|
||||
expect(readonly("WITH t AS (SELECT 1) SELECT * FROM t")).toBe("plain");
|
||||
expect(readonly("SELECT 1")).toBe("plain");
|
||||
expect(readonly("SELECT *")).toBe("plain");
|
||||
expect(readonly("SELECT * INTO copy FROM users")).toBe("plain");
|
||||
expect(readonly("INSERT INTO users VALUES (1)")).toBe("plain");
|
||||
expect(readonly("UPDATE users SET id = 1")).toBe("plain");
|
||||
expect(readonly("DELETE FROM users")).toBe("plain");
|
||||
expect(readonly("TABLE users", "postgres")).toBe("plain");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"SELECT id, (SELECT name FROM logs LIMIT 1) FROM users",
|
||||
"SELECT * FROM users WHERE id IN (SELECT id FROM logs)",
|
||||
"SELECT id, coalesce(sum(id), 0) FROM users",
|
||||
"SELECT id, (row_number() OVER ()) FROM users",
|
||||
"SELECT * FROM db.public.users",
|
||||
"SELECT * FROM users WHERE id > 0; SELECT * FROM logs",
|
||||
"SELECT id FROM ONLY users",
|
||||
"SELECT id /* outer /* inner */ FROM wrong WHERE 1=1 */ FROM users",
|
||||
String.raw`SELECT id, E'ignored\' FROM wrong WHERE 1=1 --' FROM users`,
|
||||
])("refuses ambiguous provenance: %s", (sql) => {
|
||||
expect(resultEditability(sql, "postgres").kind).toBe("readonly");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"SELECT id, name FROM users /*!50000 UNION SELECT id, name FROM admins */",
|
||||
"SELECT id, name FROM users /*M! UNION SELECT id, name FROM admins */",
|
||||
])("rejects executable comments: %s", (sql) => {
|
||||
expect(resultEditability(sql, "mysql").kind).toBe("readonly");
|
||||
});
|
||||
|
||||
it("folds unquoted identifiers per dialect and preserves quoted case", () => {
|
||||
expect(editable("SELECT ID FROM PUBLIC.USERS").table).toBe("users");
|
||||
expect(editable('SELECT "ID" FROM "Users"').table).toBe("Users");
|
||||
expect(editable("SELECT id FROM users", "oracle").table).toBe("USERS");
|
||||
});
|
||||
|
||||
it("keeps aliased columns and expressions read-only per column", () => {
|
||||
expect(editable("SELECT id, name AS n FROM users").items).toEqual([
|
||||
{ kind: "column", qualifier: null, name: "id" },
|
||||
{ kind: "expression" },
|
||||
]);
|
||||
expect(editable("SELECT id, name n FROM users").items).toEqual([
|
||||
{ kind: "column", qualifier: null, name: "id" },
|
||||
{ kind: "expression" },
|
||||
]);
|
||||
expect(editable("SELECT id || '-x', id::text, 'k', 5 FROM users").items).toEqual([
|
||||
{ kind: "expression" },
|
||||
{ kind: "expression" },
|
||||
{ kind: "expression" },
|
||||
{ kind: "expression" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the table of a statement with trailing semicolon and comments", () => {
|
||||
const e = editable("SELECT id FROM users; -- latest");
|
||||
expect(e.table).toBe("users");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTarget", () => {
|
||||
const model: DbCompletion = {
|
||||
defaultSchema: "public",
|
||||
tables: [
|
||||
{ schema: "public", name: "users", kind: "table", columns: [] },
|
||||
{ schema: "other", name: "public", kind: "table", columns: [] },
|
||||
],
|
||||
foreignKeys: [],
|
||||
};
|
||||
|
||||
it("keeps a known schema.table", () => {
|
||||
const e = editable("SELECT * FROM public.users");
|
||||
expect(resolveTarget(e, model)).toEqual({ schema: "public", table: "users", alias: null });
|
||||
});
|
||||
|
||||
it("preserves explicit qualification even with incomplete completion metadata", () => {
|
||||
const e = editable("SELECT * FROM public.users");
|
||||
expect(resolveTarget(e, { ...model, tables: model.tables.slice(1) })).toEqual({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
alias: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["postgres", "mysql", "oracle"] as const)("uses the execution schema for %s", (driver) => {
|
||||
const e = editable("SELECT * FROM users u", driver);
|
||||
expect(resolveTarget(e, model, "s2")?.schema).toBe("s2");
|
||||
expect(resolveTarget(e, model)?.schema).toBe("public");
|
||||
});
|
||||
|
||||
it("passes an unknown schema.table through", () => {
|
||||
const e = editable("SELECT * FROM nope.missing");
|
||||
expect(resolveTarget(e, model)).toEqual({ schema: "nope", table: "missing", alias: null });
|
||||
});
|
||||
|
||||
it("refuses unresolved defaults and SQL Server database/schema ambiguity", () => {
|
||||
expect(resolveTarget(editable("SELECT * FROM users"), model, "")).toBeNull();
|
||||
expect(resolveTarget(editable("SELECT * FROM users"), model, "public", "sqlServer")).toBeNull();
|
||||
const explicit = editable("SELECT * FROM dbo.users");
|
||||
expect(resolveTarget(explicit, model, "other_database", "sqlServer")).toBeNull();
|
||||
expect(resolveTarget(explicit, model, "public", "sqlServer")).toEqual({
|
||||
schema: "dbo",
|
||||
table: "users",
|
||||
alias: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not touch unqualified or explicitly aliased names", () => {
|
||||
const e = editable("SELECT * FROM users u");
|
||||
expect(resolveTarget(e, null)).toEqual({ schema: null, table: "users", alias: "u" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resultColumnSources", () => {
|
||||
it("maps plain columns positionally and stars against the table's columns", () => {
|
||||
expect(resultColumnSources([{ kind: "column", qualifier: null, name: "id" }], ["id"], ["id", "name"], eq)).toEqual([
|
||||
"id",
|
||||
]);
|
||||
expect(resultColumnSources([{ kind: "star", qualifier: null }], ["id", "name"], ["id", "name"], eq)).toEqual([
|
||||
"id",
|
||||
"name",
|
||||
]);
|
||||
// A `*` column the table doesn't have (a computed column of a view): read-only.
|
||||
expect(resultColumnSources([{ kind: "star", qualifier: null }], ["id", "extra"], ["id"], eq)).toEqual(["id", null]);
|
||||
// While the details are loading, the names are trusted.
|
||||
expect(resultColumnSources([{ kind: "star", qualifier: null }], ["id", "extra"], null, eq)).toEqual([
|
||||
"id",
|
||||
"extra",
|
||||
]);
|
||||
});
|
||||
|
||||
it("aligns mixed items positionally", () => {
|
||||
const items = [{ kind: "expression" }, { kind: "star", qualifier: null }] as const;
|
||||
expect(resultColumnSources(items, ["count", "id", "name"], ["id", "name"], eq)).toEqual([null, "id", "name"]);
|
||||
});
|
||||
|
||||
it("honours the star qualifier only through the caller's qualifier check", () => {
|
||||
// The qualifier match against the alias / table name happens before mapping; here both pass.
|
||||
const items = [
|
||||
{ kind: "star", qualifier: "u" },
|
||||
{ kind: "column", qualifier: "u", name: "id" },
|
||||
] as const;
|
||||
expect(resultColumnSources([...items], ["name", "id"], ["id", "name"], eq)).toEqual(["name", "id"]);
|
||||
});
|
||||
|
||||
it("maps nothing when the items cannot line up with the result", () => {
|
||||
expect(resultColumnSources([{ kind: "column", qualifier: null, name: "a" }], ["a", "b"], null, eq)).toEqual([
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(resultColumnSources([{ kind: "star", qualifier: null }], [], null, eq)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pkResultColumns", () => {
|
||||
it("refuses duplicate result labels that could shadow a primary key", () => {
|
||||
expect(pkResultColumns(["id"], [null, "id", "name"], ["id", "id", "name"], eq)).toBeNull();
|
||||
expect(pkResultColumns(["id"], ["id", "name"], ["id", "ID"], eq)).toBeNull();
|
||||
});
|
||||
|
||||
it("collects the result columns of every key column, or null when one is missing", () => {
|
||||
expect(pkResultColumns(["id"], ["id", "name"], ["id", "name"], eq)).toEqual(["id"]);
|
||||
expect(pkResultColumns(["id", "tenant"], ["tenant", "name", "id"], ["tenant", "name", "id"], eq)).toEqual([
|
||||
"id",
|
||||
"tenant",
|
||||
]);
|
||||
expect(pkResultColumns(["id"], [null, "name"], ["id", "name"], eq)).toBeNull();
|
||||
expect(pkResultColumns([], ["id"], ["id"], eq)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editStatements", () => {
|
||||
it("renders the UPDATE / INSERT / DELETE the backend applies", () => {
|
||||
const edits: DbRowEdit[] = [
|
||||
{ kind: "update", key: [{ column: "id", value: "1" }], values: [{ column: "name", value: "O'Brien" }] },
|
||||
{ kind: "delete", key: [{ column: "id", value: "2" }] },
|
||||
{
|
||||
kind: "insert",
|
||||
values: [
|
||||
{ column: "id", value: null },
|
||||
{ column: "name", value: "x" },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(editStatements("postgres", "public", "users", edits)).toEqual([
|
||||
"UPDATE public.users SET name = 'O''Brien' WHERE id = '1'",
|
||||
"DELETE FROM public.users WHERE id = '2'",
|
||||
"INSERT INTO public.users (id, name) VALUES (NULL, 'x')",
|
||||
]);
|
||||
expect(editStatements("mysql", "", "users", [{ kind: "insert", values: [] }])).toEqual([
|
||||
"INSERT INTO users () VALUES ()",
|
||||
]);
|
||||
expect(editStatements("postgres", "", "users", [{ kind: "insert", values: [] }])).toEqual([
|
||||
"INSERT INTO users DEFAULT VALUES",
|
||||
]);
|
||||
expect(editStatements("postgres", "", "users", [{ kind: "delete", key: [{ column: "id", value: null }] }])).toEqual(
|
||||
["DELETE FROM users WHERE id IS NULL"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Whether a console result can be edited in place, like DataGrip's: only when
|
||||
* the query is a plain select of one base table's rows — no joins, derived
|
||||
* tables, CTEs, `DISTINCT` / `GROUP BY` / `HAVING` / window functions or set
|
||||
* operations — and its select list maps to that table's columns (plain column
|
||||
* references and `*`; aliased columns and expressions stay read-only). Purely
|
||||
* lexical over the statement's tokens, like the completion analysis.
|
||||
*/
|
||||
import type { DbCellValue, DbCompletion, DbDriver, DbRowEdit } from "@/platform/ipc";
|
||||
import { quoteIdent } from "./sql/complete";
|
||||
import { CLAUSE_WORDS, functions } from "./sql/dialect";
|
||||
import { identName, lex, type Token } from "./sql/lexer";
|
||||
|
||||
const sqlName = (token: Token, driver: DbDriver): string => {
|
||||
const name = identName(token);
|
||||
if (token.kind === "quoted") return name;
|
||||
return driver === "postgres" ? name.toLowerCase() : driver === "oracle" ? name.toUpperCase() : name;
|
||||
};
|
||||
|
||||
/** One select list item. */
|
||||
export type SelectItem =
|
||||
/** `*` or `t.*`: every column of the table. */
|
||||
| { kind: "star"; qualifier: string | null }
|
||||
/** A plain column reference (`col`, `t.col`…); `qualifier` is what it is qualified with, when written. */
|
||||
| { kind: "column"; qualifier: string | null; name: string }
|
||||
/** Anything else — an aliased column, a literal, a function call…: the column stays read-only. */
|
||||
| { kind: "expression" };
|
||||
|
||||
/** Why a result cannot be edited. */
|
||||
export type EditBlockReason =
|
||||
/** The query joins several tables. */
|
||||
| "joined"
|
||||
/** `DISTINCT`, `GROUP BY` / `HAVING`, a window function or an aggregate. */
|
||||
| "aggregated"
|
||||
/** `UNION` / `INTERSECT` / `EXCEPT`. */
|
||||
| "setOp"
|
||||
/** Not a plain select of one base table (another verb, a CTE, a derived table, no `FROM`…). */
|
||||
| "plain";
|
||||
|
||||
export type ResultEditability =
|
||||
| {
|
||||
kind: "editable";
|
||||
/** The table's schema as written (`null`: unqualified). */
|
||||
schema: string | null;
|
||||
table: string;
|
||||
/** The FROM item's alias, when written (`users u`, `users AS u`). */
|
||||
alias: string | null;
|
||||
/** The select list, positionally aligned with the result's columns. */
|
||||
items: SelectItem[];
|
||||
}
|
||||
| { kind: "readonly"; reason: EditBlockReason };
|
||||
|
||||
const SET_OPS = new Set(["UNION", "INTERSECT", "EXCEPT", "MINUS"]);
|
||||
const JOIN_WORDS = new Set([
|
||||
"JOIN",
|
||||
"INNER",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"FULL",
|
||||
"CROSS",
|
||||
"NATURAL",
|
||||
"OUTER",
|
||||
"STRAIGHT_JOIN",
|
||||
"LATERAL",
|
||||
]);
|
||||
/** Words that end the FROM items (the clauses that may follow them). */
|
||||
const AFTER_FROM = new Set(["WHERE", "ORDER", "LIMIT", "OFFSET", "FETCH", "FOR"]);
|
||||
|
||||
const isIdent = (t: Token | undefined): t is Token =>
|
||||
!!t && (t.kind === "quoted" || (t.kind === "word" && !CLAUSE_WORDS.has(t.upper)));
|
||||
const isStar = (t: Token | undefined) => !!t && t.kind === "punct" && t.text === "*";
|
||||
const readonly = (reason: EditBlockReason): ResultEditability => ({ kind: "readonly", reason });
|
||||
|
||||
/** The aggregate functions of the driver's dialect (`COUNT`, `SUM`…). */
|
||||
const aggregatesOf = (driver: DbDriver) =>
|
||||
new Set(
|
||||
functions(driver)
|
||||
.filter((f) => f.aggregate)
|
||||
.map((f) => f.name.toUpperCase()),
|
||||
);
|
||||
|
||||
/** Length of the identifier chain (`a`, `a.b`, `a.b.c`…, dots included) the tokens start with. */
|
||||
const chainLength = (tokens: readonly Token[]): number => {
|
||||
if (!isIdent(tokens[0])) return 0;
|
||||
let n = 1;
|
||||
while (tokens[n]?.kind === "punct" && tokens[n]!.text === "." && isIdent(tokens[n + 1])) n += 2;
|
||||
return n;
|
||||
};
|
||||
|
||||
/** One select list item: an identifier chain, plain or ending in `.*`, or an expression. */
|
||||
const selectItemOf = (item: readonly Token[], driver: DbDriver): SelectItem => {
|
||||
if (item.length === 1 && isStar(item[0])) return { kind: "star", qualifier: null };
|
||||
if (item.length >= 3 && isStar(item.at(-1)) && item.at(-2)!.text === "." && item.at(-2)!.kind === "punct") {
|
||||
const chain = item.slice(0, -2);
|
||||
const n = chainLength(chain);
|
||||
return n === chain.length && n > 0
|
||||
? { kind: "star", qualifier: sqlName(chain.at(-1)!, driver) }
|
||||
: { kind: "expression" };
|
||||
}
|
||||
const n = chainLength(item);
|
||||
if (n !== item.length || n === 0) return { kind: "expression" };
|
||||
return n === 1
|
||||
? { kind: "column", qualifier: null, name: sqlName(item[0]!, driver) }
|
||||
: // `a.b` / `a.b.c`: the qualifier is the ident before the last dot.
|
||||
{ kind: "column", qualifier: sqlName(item.at(-3)!, driver), name: sqlName(item.at(-1)!, driver) };
|
||||
};
|
||||
|
||||
/**
|
||||
* What a console statement's result grid can do: `editable` with the base
|
||||
* table and the select list, or `readonly` with the reason.
|
||||
*/
|
||||
export function resultEditability(text: string, driver: DbDriver): ResultEditability {
|
||||
const lexed = lex(text, driver);
|
||||
if (lexed.comments.some((c) => text.slice(c.from + 2, c.to).includes("/*"))) return readonly("plain");
|
||||
if (driver === "mysql" && lexed.comments.some((c) => /^\/\*(?:!|M!)/i.test(text.slice(c.from, c.to))))
|
||||
return readonly("plain");
|
||||
const all = lexed.tokens;
|
||||
if (driver === "postgres" && all.some((t) => t.kind === "string" && t.text.includes("\\"))) return readonly("plain");
|
||||
if (all.some((t) => t.kind === "word" && SET_OPS.has(t.upper))) return readonly("setOp");
|
||||
if (all.at(-1)?.text === ";") all.pop();
|
||||
if (
|
||||
all.some(
|
||||
(t, i) =>
|
||||
(t.kind === "word" && (t.upper === "SELECT" || t.upper === "WITH") && i > 0) ||
|
||||
(t.kind === "punct" && t.text === ";"),
|
||||
)
|
||||
)
|
||||
return readonly("plain");
|
||||
const start = 0;
|
||||
const t0 = all[start];
|
||||
if (!t0 || t0.kind !== "word" || t0.upper !== "SELECT") return readonly("plain");
|
||||
const aggregates = aggregatesOf(driver);
|
||||
|
||||
// Top-level landmarks over the whole statement (the lexer dropped comments).
|
||||
let depth = 0;
|
||||
let from = -1;
|
||||
for (let j = start; j < all.length; j++) {
|
||||
const tok = all[j]!;
|
||||
if (tok.kind === "punct") {
|
||||
if (tok.text === "(") depth++;
|
||||
else if (tok.text === ")") depth--;
|
||||
}
|
||||
if (tok.kind === "word" && (tok.upper === "OVER" || tok.upper === "WINDOW")) return readonly("aggregated");
|
||||
if (tok.kind === "word" && aggregates.has(tok.upper) && all[j + 1]?.text === "(") return readonly("aggregated");
|
||||
if (depth !== 0 || tok.kind !== "word") continue;
|
||||
const w = tok.upper;
|
||||
if (j === start + 1 && w === "DISTINCT") return readonly("aggregated");
|
||||
if (w === "INTO") return readonly("plain");
|
||||
if (w === "GROUP" || w === "HAVING") return readonly("aggregated");
|
||||
if (w === "FROM" && from < 0) from = j;
|
||||
}
|
||||
if (from < 0) return readonly("plain");
|
||||
|
||||
const selectList = all.slice(start + 1, from);
|
||||
if (!selectList.length) return readonly("plain");
|
||||
|
||||
// Select items: top-level commas separate them.
|
||||
const items: SelectItem[] = [];
|
||||
let itemStart = 0;
|
||||
let itemDepth = 0;
|
||||
for (let j = 0; j <= selectList.length; j++) {
|
||||
const tok = selectList[j];
|
||||
const ends = j === selectList.length;
|
||||
if (tok?.kind === "punct") {
|
||||
if (tok.text === "(") itemDepth++;
|
||||
else if (tok.text === ")") itemDepth--;
|
||||
}
|
||||
if (!ends && !(itemDepth === 0 && tok!.kind === "punct" && tok!.text === ",")) continue;
|
||||
const item = selectList.slice(itemStart, j);
|
||||
itemStart = j + 1;
|
||||
if (item.length) items.push(selectItemOf(item, driver));
|
||||
}
|
||||
if (!items.length) return readonly("plain");
|
||||
|
||||
// The FROM items: one `[schema.]table` (with an optional alias) and nothing else.
|
||||
const rest = all.slice(from + 1);
|
||||
let end = rest.length;
|
||||
depth = 0;
|
||||
for (let j = 0; j < rest.length; j++) {
|
||||
const tok = rest[j]!;
|
||||
if (tok.kind === "punct") {
|
||||
if (tok.text === "(") depth++;
|
||||
else if (tok.text === ")") depth--;
|
||||
}
|
||||
if (depth === 0 && tok.kind === "word" && AFTER_FROM.has(tok.upper)) {
|
||||
end = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const item = rest.slice(0, end);
|
||||
if (item[0]?.kind === "word" && item[0].upper === "ONLY") return readonly("plain");
|
||||
// A statement's trailing `;` is not part of the FROM item.
|
||||
while (item.at(-1)?.kind === "punct" && item.at(-1)!.text === ";") item.pop();
|
||||
if (!item.length || item[0]!.kind === "punct") return readonly("plain"); // a derived table or a parenthesized join
|
||||
const n = chainLength(item);
|
||||
if (!n || n > 3) return readonly("plain");
|
||||
let at = n;
|
||||
let alias: string | null = null;
|
||||
if (item[at]?.kind === "word" && item[at]!.upper === "AS") {
|
||||
at++;
|
||||
if (!isIdent(item[at])) return readonly("plain");
|
||||
alias = sqlName(item[at]!, driver);
|
||||
at++;
|
||||
} else if (isIdent(item[at])) {
|
||||
alias = sqlName(item[at]!, driver);
|
||||
at++;
|
||||
}
|
||||
if (at < item.length) {
|
||||
const tok = item[at]!;
|
||||
if (tok.kind === "punct" && tok.text === ",") return readonly("joined");
|
||||
if (tok.kind === "word" && JOIN_WORDS.has(tok.upper)) return readonly("joined");
|
||||
return readonly("plain");
|
||||
}
|
||||
return {
|
||||
kind: "editable",
|
||||
// The ident before the last one (`public.users`, `db.schema.table`; dots are part of `n`).
|
||||
schema: n >= 3 ? sqlName(item[n - 3]!, driver) : null,
|
||||
table: sqlName(item[n - 1]!, driver),
|
||||
alias,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve unqualified names in the schema used to execute this result. */
|
||||
export function resolveTarget(
|
||||
e: Extract<ResultEditability, { kind: "editable" }>,
|
||||
model: DbCompletion | null,
|
||||
schema?: string,
|
||||
driver: DbDriver = "postgres",
|
||||
): { schema: string | null; table: string; alias: string | null } | null {
|
||||
// SQL Server switches databases, but table details and edits take a table schema.
|
||||
if (driver === "sqlServer" && (e.schema === null || (schema && schema !== model?.defaultSchema))) return null;
|
||||
if (e.schema === null && schema === "") return null;
|
||||
return { schema: e.schema ?? (schema || model?.defaultSchema || null), table: e.table, alias: e.alias };
|
||||
}
|
||||
|
||||
/**
|
||||
* The table column behind each result column: the select items aligned
|
||||
* positionally with the result's columns. `null` for expressions and aliased
|
||||
* columns, and for `*` columns the table doesn't have. `tableColumns` may be
|
||||
* `null` while the table's details are loading (the names are then trusted).
|
||||
*/
|
||||
export function resultColumnSources(
|
||||
items: readonly SelectItem[],
|
||||
resultNames: readonly string[],
|
||||
tableColumns: readonly string[] | null,
|
||||
eq: (a: string | null | undefined, b: string | null | undefined) => boolean,
|
||||
): Array<string | null> {
|
||||
const sources: Array<string | null> = resultNames.map(() => null);
|
||||
let r = 0;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]!;
|
||||
const pending = items.length - i - 1;
|
||||
if (r >= resultNames.length) break;
|
||||
if (item.kind === "column") {
|
||||
const name = tableColumns?.find((c) => eq(c, item.name)) ?? (tableColumns ? null : item.name);
|
||||
sources[r] = name && eq(resultNames[r], name) ? name : null;
|
||||
r++;
|
||||
} else if (item.kind === "expression") {
|
||||
r++;
|
||||
} else {
|
||||
// `*`: every result column left for it (the items after it need theirs).
|
||||
const take = Math.max(0, resultNames.length - r - pending);
|
||||
for (let k = 0; k < take && r < resultNames.length; k++, r++) {
|
||||
const name = resultNames[r]!;
|
||||
if (!tableColumns || tableColumns.some((c) => eq(c, name))) sources[r] = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return r === resultNames.length ? sources : resultNames.map(() => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The result columns that carry the table's primary key (their names go into
|
||||
* the generated `UPDATE` / `DELETE`), or `null` when the result doesn't
|
||||
* include every key column.
|
||||
*/
|
||||
export function pkResultColumns(
|
||||
tablePk: readonly string[],
|
||||
sources: ReadonlyArray<string | null>,
|
||||
resultNames: readonly string[],
|
||||
eq: (a: string | null | undefined, b: string | null | undefined) => boolean,
|
||||
): string[] | null {
|
||||
if (!tablePk.length || resultNames.some((name, i) => resultNames.slice(0, i).some((other) => eq(name, other))))
|
||||
return null;
|
||||
const out: string[] = [];
|
||||
for (const key of tablePk) {
|
||||
const at = sources.findIndex((s) => s !== null && eq(s, key));
|
||||
if (at < 0) return null;
|
||||
out.push(resultNames[at]!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const literalOf = (driver: DbDriver, v: string | null): string => {
|
||||
if (v === null) return "NULL";
|
||||
let s = v.replace(/'/g, "''");
|
||||
if (driver === "mysql" || driver === "clickhouse") s = s.replace(/\\/g, "\\\\");
|
||||
return `'${s}'`;
|
||||
};
|
||||
|
||||
const conditionOf = (driver: DbDriver, key: readonly DbCellValue[]): string =>
|
||||
key
|
||||
.map((c) =>
|
||||
c.value === null
|
||||
? `${quoteIdent(c.column, driver)} IS NULL`
|
||||
: `${quoteIdent(c.column, driver)} = ${literalOf(driver, c.value)}`,
|
||||
)
|
||||
.join(" AND ");
|
||||
|
||||
/**
|
||||
* The statements `dbApplyEdits` runs for `edits`, for the console's Output
|
||||
* log: the same `UPDATE` / `INSERT` / `DELETE` the backend generates.
|
||||
*/
|
||||
export function editStatements(driver: DbDriver, schema: string, table: string, edits: readonly DbRowEdit[]): string[] {
|
||||
const quoted = (name: string) => quoteIdent(name, driver);
|
||||
const target = schema ? `${quoted(schema)}.${quoted(table)}` : quoted(table);
|
||||
return edits.map((e) => {
|
||||
const literal = (c: DbCellValue) => `${quoted(c.column)} = ${literalOf(driver, c.value)}`;
|
||||
switch (e.kind) {
|
||||
case "update":
|
||||
return `UPDATE ${target} SET ${e.values.map(literal).join(", ")} WHERE ${conditionOf(driver, e.key)}`;
|
||||
case "delete":
|
||||
return `DELETE FROM ${target} WHERE ${conditionOf(driver, e.key)}`;
|
||||
default:
|
||||
return e.values.length
|
||||
? `INSERT INTO ${target} (${e.values.map((c) => quoted(c.column)).join(", ")}) VALUES (${e.values
|
||||
.map((c) => literalOf(driver, c.value))
|
||||
.join(", ")})`
|
||||
: driver === "mysql"
|
||||
? `INSERT INTO ${target} () VALUES ()`
|
||||
: `INSERT INTO ${target} DEFAULT VALUES`;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DbDriver, DbQueryResult, DbTableDetails } from "@/platform/ipc";
|
||||
import { ResultsView } from "./ResultsView";
|
||||
import { executeConsole, useDatabase, type ResultTab } from "./store";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
driver: "postgres" as DbDriver,
|
||||
readOnly: false,
|
||||
details: vi.fn(),
|
||||
applyEdits: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
statements: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...actual,
|
||||
commands: {
|
||||
...actual.commands,
|
||||
dbTableDetails: backend.details,
|
||||
dbApplyEdits: backend.applyEdits,
|
||||
dbExecute: backend.execute,
|
||||
dbStatements: backend.statements,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
getTotalSize: () => count * 24,
|
||||
getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, start: index * 24, key: index })),
|
||||
scrollToIndex: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const details: DbTableDetails = {
|
||||
columns: [
|
||||
{ name: "id", dataType: "int4", primaryKey: true, nullable: false, default: null },
|
||||
{ name: "name", dataType: "text", primaryKey: false, nullable: false, default: null },
|
||||
],
|
||||
indexes: [],
|
||||
foreignKeys: [],
|
||||
triggers: [],
|
||||
};
|
||||
|
||||
const result = (columns: Array<{ name: string }>, rows: (string | null)[][]): DbQueryResult => ({
|
||||
statement: "select",
|
||||
columns: columns.map((c) => ({ ...c, typeName: "text" })),
|
||||
rows,
|
||||
affected: null,
|
||||
hasMore: false,
|
||||
elapsedMs: 1,
|
||||
executionMs: 1,
|
||||
});
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
function show(text: string, columns: Array<{ name: string }>, rows: (string | null)[][]) {
|
||||
const outcome = { statement: { from: 0, to: text.length, text }, result: result(columns, rows), error: null };
|
||||
const tab: ResultTab = {
|
||||
id: nextId++,
|
||||
title: "Result 1",
|
||||
path: "console.sql",
|
||||
sourceId: "pg",
|
||||
outcome,
|
||||
page: 0,
|
||||
pageSize: 500,
|
||||
};
|
||||
useDatabase.setState({
|
||||
sources: [{ id: "pg", name: "Local", driver: backend.driver, readOnly: backend.readOnly }],
|
||||
completion: {
|
||||
pg: {
|
||||
defaultSchema: "public",
|
||||
tables: [{ schema: "public", name: "users", kind: "table", columns: [] }],
|
||||
foreignKeys: [],
|
||||
},
|
||||
},
|
||||
results: [tab],
|
||||
activeResult: tab.id,
|
||||
output: [],
|
||||
details: {},
|
||||
consoleSchemas: {},
|
||||
});
|
||||
return tab;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
backend.statements.mockImplementation((_id: unknown, text: string) =>
|
||||
Promise.resolve({ status: "ok", data: [{ from: 0, to: text.length, text }] }),
|
||||
);
|
||||
backend.driver = "postgres";
|
||||
backend.readOnly = false;
|
||||
backend.details.mockResolvedValue({ status: "ok", data: details });
|
||||
backend.applyEdits.mockResolvedValue({ status: "ok", data: 1 });
|
||||
backend.execute.mockImplementation((_request: unknown, _id: unknown, statements: Array<{ text: string }>) => {
|
||||
const st = statements[0]!;
|
||||
const r = result(
|
||||
[{ name: "id" }, { name: "name" }],
|
||||
[
|
||||
["1", "Ann"],
|
||||
["2", "Bob"],
|
||||
],
|
||||
);
|
||||
return Promise.resolve({
|
||||
status: "ok",
|
||||
data: [{ statement: st, result: { ...r, statement: st.text }, error: null }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("editable result grid", () => {
|
||||
it("keeps every cell of a pasted row in the submitted edits", async () => {
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
await act(async () => render(<ResultsView />));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Row" }));
|
||||
const cells = screen.getAllByRole("gridcell");
|
||||
fireEvent.mouseDown(cells[2]!, { button: 0 });
|
||||
fireEvent.paste(screen.getByRole("grid"), { clipboardData: { getData: () => "3\tCara" } });
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Submit" })));
|
||||
expect(backend.applyEdits).toHaveBeenCalledWith("pg", "public", "users", [
|
||||
{
|
||||
kind: "insert",
|
||||
values: [
|
||||
{ column: "id", value: "3" },
|
||||
{ column: "name", value: "Cara" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("makes a single-table select with its primary key editable and submits in one transaction", async () => {
|
||||
const tab = show("SELECT * FROM users WHERE id < 10", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
await act(async () => render(<ResultsView />));
|
||||
expect(screen.queryByRole("note")).toBeNull();
|
||||
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
|
||||
const input = screen.getByRole("textbox", { name: "Edit name" });
|
||||
fireEvent.change(input, { target: { value: "Bob" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByRole("gridcell", { name: "Bob" })).toBeTruthy();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
});
|
||||
expect(backend.applyEdits).toHaveBeenCalledWith("pg", "public", "users", [
|
||||
{ kind: "update", key: [{ column: "id", value: "1" }], values: [{ column: "name", value: "Bob" }] },
|
||||
]);
|
||||
// The generated statement is in the console's Output log, and the grid refreshed.
|
||||
const output = useDatabase.getState().output.map((l) => l.text);
|
||||
expect(output).toContain("UPDATE public.users SET name = 'Bob' WHERE id = '1'");
|
||||
await screen.findByRole("gridcell", { name: "Ann" });
|
||||
expect(useDatabase.getState().results[0]!.id).toBe(tab.id);
|
||||
});
|
||||
|
||||
it.each(["s2", ""])("records the selected schema or connection default: %s", async (schema) => {
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
useDatabase.setState({ consoles: { "console.sql": "pg" }, consoleSchemas: { "console.sql": schema } });
|
||||
await executeConsole("console.sql", "SELECT * FROM users", 0, 0);
|
||||
const expected = schema || "public";
|
||||
expect(backend.execute.mock.calls.at(-1)?.at(-1)).toBe(expected);
|
||||
expect(useDatabase.getState().results.at(-1)?.schema).toBe(expected);
|
||||
});
|
||||
|
||||
it("prevents repeated submission while edits are being applied", async () => {
|
||||
let finish!: (value: { status: string; data: number }) => void;
|
||||
backend.applyEdits.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
await act(async () => render(<ResultsView />));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Row" }));
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Submit" })));
|
||||
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Add Row" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
||||
expect(backend.applyEdits).toHaveBeenCalledTimes(1);
|
||||
await act(async () => finish({ status: "ok", data: 1 }));
|
||||
});
|
||||
|
||||
it("uses the execution schema even after the console selection changes", async () => {
|
||||
const tab = show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
Object.assign(tab, { schema: "s2" });
|
||||
useDatabase.setState({ consoleSchemas: { "console.sql": "s1" } });
|
||||
await act(async () => render(<ResultsView />));
|
||||
expect(backend.details).toHaveBeenCalledWith("pg", "s2", "users");
|
||||
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
|
||||
const input = screen.getByRole("textbox", { name: "Edit name" });
|
||||
fireEvent.change(input, { target: { value: "Changed" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Submit" })));
|
||||
expect(backend.applyEdits).toHaveBeenCalledWith("pg", "s2", "users", [
|
||||
{ kind: "update", key: [{ column: "id", value: "1" }], values: [{ column: "name", value: "Changed" }] },
|
||||
]);
|
||||
expect(backend.execute.mock.calls.at(-1)?.at(-1)).toBe("s2");
|
||||
});
|
||||
|
||||
it("preserves pending edits and shows the error when submit fails", async () => {
|
||||
backend.applyEdits.mockResolvedValue({ status: "error", error: { kind: "invalid", message: "Duplicate key" } });
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
await act(async () => render(<ResultsView />));
|
||||
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
|
||||
const input = screen.getByRole("textbox", { name: "Edit name" });
|
||||
fireEvent.change(input, { target: { value: "Changed" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Submit" })));
|
||||
expect(screen.getByRole("gridcell", { name: "Changed" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
|
||||
expect(useDatabase.getState().output.some((line) => line.kind === "error" && line.text === "Duplicate key")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(backend.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("highlights the edit and reverts it", async () => {
|
||||
show("SELECT * FROM users WHERE id < 10", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
await act(async () => render(<ResultsView />));
|
||||
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
|
||||
const input = screen.getByRole("textbox", { name: "Edit name" });
|
||||
fireEvent.change(input, { target: { value: "Bob" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByRole("button", { name: "Submit" }).hasAttribute("disabled")).toBe(false);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Revert" }));
|
||||
});
|
||||
expect(screen.getByRole("gridcell", { name: "Ann" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Submit" }).hasAttribute("disabled")).toBe(true);
|
||||
expect(backend.applyEdits).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an aliased column read-only while the plain ones stay editable", async () => {
|
||||
show("SELECT id, name AS n FROM users", [{ name: "id" }, { name: "n" }], [["1", "Ann"]]);
|
||||
await act(async () => render(<ResultsView />));
|
||||
expect(screen.queryByRole("note")).toBeNull();
|
||||
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
|
||||
const input = screen.getByRole("textbox", { name: "Edit n" });
|
||||
fireEvent.change(input, { target: { value: "Bob" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
// The aliased column ignores the edit; Submit stays out of reach.
|
||||
expect(screen.getByRole("gridcell", { name: "Ann" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Submit" }).hasAttribute("disabled")).toBe(true);
|
||||
expect(backend.applyEdits).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("read-only results with a reason", () => {
|
||||
it.each([
|
||||
["SELECT * FROM a JOIN b ON a.id = b.id", [{ name: "id" }], [["1"]], "Read-only: the query joins several tables."],
|
||||
[
|
||||
"SELECT count(*) FROM users",
|
||||
[{ name: "count" }],
|
||||
[["2"]],
|
||||
"Read-only: the result is aggregated (DISTINCT, GROUP BY or an aggregate function).",
|
||||
],
|
||||
[
|
||||
"SELECT id FROM a UNION SELECT id FROM b",
|
||||
[{ name: "id" }],
|
||||
[["1"]],
|
||||
"Read-only: the result combines several queries (UNION / INTERSECT / EXCEPT).",
|
||||
],
|
||||
[
|
||||
"SELECT name FROM users",
|
||||
[{ name: "name" }],
|
||||
[["Ann"]],
|
||||
"Read-only: the result has no primary key of public.users.",
|
||||
],
|
||||
])("explains %s", async (text, columns, rows, expected) => {
|
||||
show(text, columns, rows);
|
||||
await act(async () => render(<ResultsView />));
|
||||
expect(await screen.findByRole("note")).toHaveTextContent(expected);
|
||||
fireEvent.doubleClick(screen.getByRole("gridcell", { name: String(rows[0]![0]) }));
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Submit" })).toBeNull();
|
||||
expect(backend.applyEdits).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an unresolved SQL Server table schema read-only", async () => {
|
||||
backend.driver = "sqlServer";
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
await act(async () => render(<ResultsView />));
|
||||
expect(await screen.findByRole("note")).toHaveTextContent(
|
||||
"Read-only: the table schema could not be identified in the result database.",
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Submit" })).toBeNull();
|
||||
expect(backend.details).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["postgres", "sqlServer"] as const)("matches case-sensitive key columns exactly for %s", async (driver) => {
|
||||
backend.driver = driver;
|
||||
show('SELECT "ID", name FROM public.users', [{ name: "ID" }, { name: "name" }], [["1", "Ann"]]);
|
||||
await act(async () => render(<ResultsView />));
|
||||
expect(await screen.findByRole("note")).toHaveTextContent(
|
||||
"Read-only: the result has no primary key of public.users.",
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Submit" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps ClickHouse read-only", async () => {
|
||||
backend.driver = "clickhouse";
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
await act(async () => render(<ResultsView />));
|
||||
expect(await screen.findByRole("note")).toHaveTextContent("Use the query console to change ClickHouse data.");
|
||||
expect(screen.queryByRole("button", { name: "Submit" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a read-only data source read-only", async () => {
|
||||
backend.readOnly = true;
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
await act(async () => render(<ResultsView />));
|
||||
expect(await screen.findByRole("note")).toHaveTextContent("Read-only data source.");
|
||||
expect(screen.queryByRole("button", { name: "Submit" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a table without a primary key read-only", async () => {
|
||||
backend.details.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: { ...details, columns: details.columns.map((c) => ({ ...c, primaryKey: false })) },
|
||||
});
|
||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
await act(async () => render(<ResultsView />));
|
||||
expect(await screen.findByRole("note")).toHaveTextContent(
|
||||
"Read-only: the table has no primary key to identify rows.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,8 @@ export interface ResultTab {
|
||||
/** Console file the statement came from. */
|
||||
path: string;
|
||||
sourceId: string;
|
||||
/** Schema used for these rows; retained across edits, paging and refresh. */
|
||||
schema?: string;
|
||||
outcome: DbStatementOutcome;
|
||||
/** The Query Plan tab: the plan of `outcome.statement` (which was explained, not run). */
|
||||
plan?: DbPlanNode[];
|
||||
@@ -869,19 +871,13 @@ export async function executeConsole(
|
||||
useDatabase.setState((s) => ({ running: { ...s.running, [path]: request } }));
|
||||
try {
|
||||
const pageSize = useDatabase.getState().maxRows;
|
||||
const outcomes = await run(
|
||||
id,
|
||||
statements,
|
||||
request,
|
||||
pageSize,
|
||||
0,
|
||||
useDatabase.getState().consoleSchemas[path] || undefined,
|
||||
);
|
||||
const schema = consoleSchema(path) || (await loadCompletion(id))?.defaultSchema || "";
|
||||
const outcomes = await run(id, statements, request, pageSize, 0, schema);
|
||||
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, page: 0, pageSize };
|
||||
return { id: n, title: tx`Result ${n}`, path, sourceId: id, schema, outcome, page: 0, pageSize };
|
||||
});
|
||||
const failed = outcomes.some((o) => o.error);
|
||||
useDatabase.setState((s) => ({
|
||||
@@ -1008,11 +1004,7 @@ export async function rerunResult(tabId: number): Promise<void> {
|
||||
patchResult(tabId, { busy: true });
|
||||
try {
|
||||
patchResult(tabId, {
|
||||
plan: await explain(
|
||||
tab.sourceId,
|
||||
tab.outcome.statement,
|
||||
useDatabase.getState().consoleSchemas[tab.path] || undefined,
|
||||
),
|
||||
plan: await explain(tab.sourceId, tab.outcome.statement, tab.schema ?? consoleSchema(tab.path)),
|
||||
busy: false,
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -1051,7 +1043,7 @@ export async function loadResultPage(tabId: number, page: number, pageSize: numb
|
||||
nextRequestId(),
|
||||
maxRows,
|
||||
skip,
|
||||
useDatabase.getState().consoleSchemas[tab.path] || undefined,
|
||||
tab.schema ?? consoleSchema(tab.path),
|
||||
);
|
||||
const r = outcome?.result;
|
||||
if (r) {
|
||||
@@ -1096,7 +1088,7 @@ export async function countResult(tabId: number): Promise<number | null> {
|
||||
nextRequestId(),
|
||||
1,
|
||||
0,
|
||||
useDatabase.getState().consoleSchemas[tab.path] || undefined,
|
||||
tab.schema ?? consoleSchema(tab.path),
|
||||
);
|
||||
const total = Number(outcome?.result?.rows[0]?.[0]);
|
||||
const known = outcome?.error || !Number.isFinite(total) ? null : total;
|
||||
|
||||
@@ -202,6 +202,13 @@
|
||||
"Docker connections come from the Services tool window. Each run gets a new container, removed when it ends. Python configurations can be debugged there; coverage and profiling stay on this machine.": "Docker connections come from the Services tool window. Each run gets a new container, removed when it ends. Python configurations can be debugged there; coverage and profiling stay on this machine.",
|
||||
"Use Project JDK": "Use Project JDK",
|
||||
"JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence.": "JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence.",
|
||||
"Read-only: the table has no primary key to identify rows.": "Read-only: the table has no primary key to identify rows.",
|
||||
"Read-only: the query joins several tables.": "Read-only: the query joins several tables.",
|
||||
"Read-only: the result is aggregated (DISTINCT, GROUP BY or an aggregate function).": "Read-only: the result is aggregated (DISTINCT, GROUP BY or an aggregate function).",
|
||||
"Read-only: the result combines several queries (UNION / INTERSECT / EXCEPT).": "Read-only: the result combines several queries (UNION / INTERSECT / EXCEPT).",
|
||||
"Read-only: the result is not a plain select from a single table.": "Read-only: the result is not a plain select from a single table.",
|
||||
"Read-only: the result has no primary key of {tableLabel}.": "Read-only: the result has no primary key of {tableLabel}.",
|
||||
"Read-only: the table schema could not be identified in the result database.": "Read-only: the table schema could not be identified in the result database.",
|
||||
"Starting the dev container…": "Starting the dev container…",
|
||||
"Command-line tool": "Command-line tool",
|
||||
"Forwarding ports is not supported in a dev container yet": "Forwarding ports is not supported in a dev container yet",
|
||||
|
||||
@@ -4854,6 +4854,11 @@
|
||||
"Read-only data source.": "只读数据源。",
|
||||
"Use the query console to change ClickHouse data.": "请使用查询控制台修改 ClickHouse 数据。",
|
||||
"Read-only: the table has no primary key to identify rows.": "只读:该表没有可用于标识行的主键。",
|
||||
"Read-only: the query joins several tables.": "只读:查询联接了多个表。",
|
||||
"Read-only: the result is aggregated (DISTINCT, GROUP BY or an aggregate function).": "只读:结果已聚合(DISTINCT、GROUP BY 或聚合函数)。",
|
||||
"Read-only: the result combines several queries (UNION / INTERSECT / EXCEPT).": "只读:结果组合了多个查询(UNION / INTERSECT / EXCEPT)。",
|
||||
"Read-only: the result is not a plain select from a single table.": "只读:结果不是对单个表的普通 SELECT 查询。",
|
||||
"Read-only: the result has no primary key of {tableLabel}.": "只读:结果中没有 {tableLabel} 的主键。",
|
||||
"The session has finished.": "会话已结束。",
|
||||
"Variables are available while suspended.": "变量仅在挂起时可用。",
|
||||
"Verified": "已验证",
|
||||
@@ -6586,5 +6591,6 @@
|
||||
"debugpy did not become ready within 120 seconds. Check the Run console and the container's Python interpreter.": "debugpy 未在 120 秒内就绪。请检查运行控制台和容器的 Python 解释器。",
|
||||
"Use Project JDK": "使用项目 SDK",
|
||||
"JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence.": "用于 Maven 同步、构建、目标和测试的 JDK。运行配置中显式设置的 JAVA_HOME 优先。",
|
||||
"Read-only: the table schema could not be identified in the result database.": "只读:无法确定结果所在数据库中的表架构。",
|
||||
"devcontainer up exited with {status}": "devcontainer up 已退出,状态为 {status}"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача