diff --git a/src/features/database/ResultsView.tsx b/src/features/database/ResultsView.tsx index b3a1505f9..3033289a0 100644 --- a/src/features/database/ResultsView.tsx +++ b/src/features/database/ResultsView.tsx @@ -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): 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("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 (
setView("table")} /> setView("chart")} /> - void rerunResult(tab.id)} /> + void rerunResult(tab.id)} /> {view === "table" && ( setSearchOpen(!searchOpen)} /> )} + {editable && ( + <> + setEdits((e) => ({ ...e, inserts: [...e.inserts, new Map()] }))} + /> + + + + )} {tab.busy && } @@ -197,9 +389,7 @@ function Result({ tab }: { tab: ResultTab }) { - showContextMenu(e, exportMenu(data(), queryTable(tab.outcome.statement.text, { driver }), extractor)) - } + onClick={(e) => showContextMenu(e, exportMenu(data(), queryTable(text, { driver }), extractor))} /> {view === "table" && }
@@ -208,21 +398,48 @@ function Result({ tab }: { tab: ResultTab }) {
) : ( -
- - -
+ <> + {notice && ( +
+ {notice} +
+ )} +
+ ({ ...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))), + })), + }, + ] + : [] + } + /> + +
+ )} ); diff --git a/src/features/database/resultEditing.test.ts b/src/features/database/resultEditing.test.ts new file mode 100644 index 000000000..6cef65bc4 --- /dev/null +++ b/src/features/database/resultEditing.test.ts @@ -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, { 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, { 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"], + ); + }); +}); diff --git a/src/features/database/resultEditing.ts b/src/features/database/resultEditing.ts new file mode 100644 index 000000000..43b625a08 --- /dev/null +++ b/src/features/database/resultEditing.ts @@ -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, + 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 { + const sources: Array = 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, + 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`; + } + }); +} diff --git a/src/features/database/results-view.test.tsx b/src/features/database/results-view.test.tsx new file mode 100644 index 000000000..b8d96c60f --- /dev/null +++ b/src/features/database/results-view.test.tsx @@ -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(); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + expect(await screen.findByRole("note")).toHaveTextContent( + "Read-only: the table has no primary key to identify rows.", + ); + }); +}); diff --git a/src/features/database/store.ts b/src/features/database/store.ts index 712267729..e651f116a 100644 --- a/src/features/database/store.ts +++ b/src/features/database/store.ts @@ -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 { 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 { 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; diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 5a5450d11..c7b71bd50 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ee33eb537..735b2e31f 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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}" }