DB viewer + query editor enhancements (home-screen-ux-query-editor) (#4)
* feat: add query_history table migration (v5) (Task 1) * feat: add isDestructiveQuery utility (Task 2) * feat: add tabType discriminator and openQueryTab to dbViewerStore (Task 3) * feat: add execute_query command with pagination and query history (Task 4) * feat: add typed wrappers for executeQuery, getQueryHistory, clearQueryHistory (Task 5) * fix: global search bypasses folder scope when filters active (Task 6) * feat: add TagFilterDropdown with checkboxes and empty state (Task 7) * feat: add DbTypeFilterDropdown with checkboxes and clear all (Task 8) * feat: wire TagFilterDropdown/DbTypeFilterDropdown into ActionRow, add inline tag creation (Task 9) * feat: add Name input to GeneralTab for connection editing (Task 10) * feat: add QueryEditor Monaco wrapper with SQL mode and Cmd+Enter (Task 11) * feat: add DestructiveQueryDialog with SQL preview and confirmation (Task 12) * feat: integrate query tabs, Monaco editor, destructive guard into DbViewerScreen (Task 13) * fix: harden moveConnection against race conditions on rapid drags (Task 14) * docs: update AGENTS.md implementation status for Home Screen UX + Query Editor (Task 15) * feat: switch tag filter to OR semantics, add environment filter (F-T16) * feat: add activeEnvironment filter state to uiStore and useFilteredConnections (F-T17) * feat: add environment filter select to Filters dropdown (F-T18) * feat: filter folder cards by tag match or contained connections (F-T19) * fix: keep grid header width to content, border last column * fix: hide select-all checkbox and empty-state when no table open * fix: filter folder cards by any active filter, show global search results (F-T20) * feat: show 'Showing Search Results' breadcrumb with clear button (F-T21) * docs: update README + AGENTS.md for Query Editor, filters, and planned AI integration (BYOK) * feat: refresh indicator with spinning icon and pulse, defer auto-refresh on tab switch * feat: smart default schema selection, refresh schemas on database switch * fix: auto-refresh waits for in-flight refresh to complete before next tick * style: shrink db viewer sidebar nav icons from 20px to 16px * style: shrink db viewer sidebar nav buttons to 32px (8px padding) * style: make Tables panel title xs, regular weight, muted * style: bump Tables panel title back to sm, keep regular weight and muted * feat: export schema diagram as PNG/JPEG/SVG (entire schema or viewport) * chore: lockfile for html-to-image * fix: raise schema visualizer toolbar above legend so export menu isn't hidden * feat: schema export via save dialog, transparent background option, save notification * fix: render nothing in tab bar when no tabs are open * style: reduce tab bar height from 40px to 36px * style: reduce tab bar height to 32px * style: revert tab bar height to 36px * feat: split tab bar with fixed +Query and Changes actions on the right * style: blue play-icon Query button in tab bar * refactor: remove sidebar New Query button (now in tab bar) * style: conditional bottom padding in sidebar toolbar when nothing is below * feat: distinguish table and query tabs with icons * style: tab icons follow active/inactive state, muted colors * feat: query tab toolbar (run/format/dialect badge) + bare transparent editor * style: blue rounded Run Query button in query toolbar * feat: smart platform-aware shortcut tooltip on Run Query (⌘+⏎ / Ctrl+Enter) * style: show only the shortcut in the Run Query tooltip * fix: Cmd+Enter keybinding stale closure; add run pulse to query toolbar; bundle monaco locally (offline) * feat: show placeholder text in empty query editor * feat: SQL autocomplete — keywords + table names from active schema * feat: per-table column autocomplete on 'table.' + docs update * feat: query-variant result toolbar — export/refresh/columns left, smart-unit execution time right * fix: populate execution_time_ms on query results so the toolbar can show time taken * fix: re-measure monaco fonts after async font load to stop cursor drift * feat: resizable + collapsible query results panel * refactor: move results caret onto the resize handle (centered), bottom caret when collapsed * style: thin drag strip with caret on its own centered pill * refactor: remove Queue button from table toolbar (Changes lives in tab bar) * style: changes button becomes bordered rounded icon with count badge * docs: mark tab-bar Changes queue button in AGENTS.md and README
This commit is contained in:
@@ -4,6 +4,7 @@ vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockResolvedValue({ tables: [], relationships: [] }),
|
||||
}));
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
testConnection,
|
||||
dbConnect,
|
||||
@@ -15,6 +16,9 @@ import {
|
||||
executeChange,
|
||||
refreshConnection,
|
||||
getSchemaGraph,
|
||||
executeQuery,
|
||||
getQueryHistory,
|
||||
clearQueryHistory,
|
||||
} from "./commands";
|
||||
import type { SchemaGraph } from "./types";
|
||||
|
||||
@@ -67,4 +71,35 @@ describe("commands", () => {
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("query commands", () => {
|
||||
it("executeQuery calls invoke with correct args", async () => {
|
||||
const mockResult = { columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 };
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await executeQuery("conn-1", "SELECT 1", 1, 50);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("execute_query", {
|
||||
connectionId: "conn-1",
|
||||
query: "SELECT 1",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
});
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it("getQueryHistory calls invoke", async () => {
|
||||
const mockHistory = [{ id: "h1", connection_id: "c1", query_text: "SELECT 1", status: "success", executed_at: "2025-01-01" }];
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockHistory);
|
||||
const result = await getQueryHistory("conn-1", 10, 0);
|
||||
expect(invoke).toHaveBeenCalledWith("get_query_history", { connectionId: "conn-1", limit: 10, offset: 0 });
|
||||
expect(result).toEqual(mockHistory);
|
||||
});
|
||||
|
||||
it("clearQueryHistory calls invoke", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await clearQueryHistory("conn-1");
|
||||
expect(invoke).toHaveBeenCalledWith("clear_query_history", { connectionId: "conn-1" });
|
||||
});
|
||||
});
|
||||
@@ -146,4 +146,38 @@ export async function getSchemaGraph(
|
||||
schema?: string,
|
||||
): Promise<SchemaGraph> {
|
||||
return invoke<SchemaGraph>("get_schema_graph", { connectionId, schema });
|
||||
}
|
||||
|
||||
// ─── Query History ──────────────────────────────────────────────
|
||||
|
||||
export interface QueryHistoryEntry {
|
||||
id: string;
|
||||
connection_id: string;
|
||||
query_text: string;
|
||||
execution_time_ms: number | null;
|
||||
row_count: number | null;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
executed_at: string;
|
||||
}
|
||||
|
||||
export async function executeQuery(
|
||||
connectionId: string,
|
||||
query: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<QueryResult> {
|
||||
return invoke<QueryResult>("execute_query", { connectionId, query, page, pageSize });
|
||||
}
|
||||
|
||||
export async function getQueryHistory(
|
||||
connectionId: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): Promise<QueryHistoryEntry[]> {
|
||||
return invoke<QueryHistoryEntry[]>("get_query_history", { connectionId, limit, offset });
|
||||
}
|
||||
|
||||
export async function clearQueryHistory(connectionId: string): Promise<void> {
|
||||
return invoke<void>("clear_query_history", { connectionId });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Local Monaco setup — bundles monaco-editor with the app so no CDN is
|
||||
* needed and the editor works fully offline.
|
||||
*
|
||||
* Must be imported once, before any @monaco-editor/react <Editor /> mounts.
|
||||
*/
|
||||
import * as monaco from "monaco-editor";
|
||||
import { loader } from "@monaco-editor/react";
|
||||
import EditorWorker from "monaco-editor/editor/editor.worker?worker";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import {
|
||||
buildSqlSuggestions,
|
||||
buildColumnSuggestions,
|
||||
getColumnsForTable,
|
||||
getCachedColumns,
|
||||
parseTableRef,
|
||||
} from "./sqlCompletion";
|
||||
|
||||
// Use the bundled editor worker (SQL has no dedicated language worker)
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: () => new EditorWorker(),
|
||||
};
|
||||
|
||||
// Hand the already-imported monaco instance to @monaco-editor/react so it
|
||||
// skips its CDN download entirely.
|
||||
loader.config({ monaco });
|
||||
|
||||
// SQL autocomplete:
|
||||
// - after `table.` (or `schema.table.`): suggest that table's columns
|
||||
// (fetched lazily via schema introspection and cached per schema)
|
||||
// - otherwise: keywords + table names from the active schema
|
||||
monaco.languages.registerCompletionItemProvider("sql", {
|
||||
provideCompletionItems: (
|
||||
model,
|
||||
position,
|
||||
): monaco.languages.CompletionList | Promise<monaco.languages.CompletionList> => {
|
||||
const { tables, currentSchema } = useDbViewerStore.getState();
|
||||
const line = model.getLineContent(position.lineNumber);
|
||||
const before = line.slice(0, position.column - 1);
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = new monaco.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn,
|
||||
);
|
||||
const withRange = (s: { label: string; insertText: string; kind: string }) => ({
|
||||
label: s.label,
|
||||
insertText: s.insertText,
|
||||
kind:
|
||||
s.kind === "keyword"
|
||||
? monaco.languages.CompletionItemKind.Keyword
|
||||
: s.kind === "table"
|
||||
? monaco.languages.CompletionItemKind.Struct
|
||||
: monaco.languages.CompletionItemKind.Field,
|
||||
range,
|
||||
});
|
||||
|
||||
const tableRef = parseTableRef(before);
|
||||
if (tableRef) {
|
||||
const schema = tableRef.schema ?? currentSchema;
|
||||
const connectionId = useUiStore.getState().activeConnectionId;
|
||||
const cached = schema ? getCachedColumns(schema, tableRef.table) : undefined;
|
||||
if (cached) {
|
||||
return {
|
||||
suggestions: buildColumnSuggestions(cached).map(withRange),
|
||||
};
|
||||
}
|
||||
// Not introspected yet: warm the cache in the background and ask Monaco
|
||||
// to re-request once the columns are available.
|
||||
void getColumnsForTable(connectionId, schema, tableRef.table);
|
||||
return { suggestions: [], incomplete: true };
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: buildSqlSuggestions(tables, currentSchema).map(withRange),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
buildSqlSuggestions,
|
||||
SQL_KEYWORDS,
|
||||
parseTableRef,
|
||||
buildColumnSuggestions,
|
||||
getColumnsForTable,
|
||||
getCachedColumns,
|
||||
} from "./sqlCompletion";
|
||||
import type { TableInfo } from "./types";
|
||||
import { getSchemaGraph } from "./commands";
|
||||
|
||||
vi.mock("./commands", () => ({
|
||||
getSchemaGraph: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetSchemaGraph = vi.mocked(getSchemaGraph);
|
||||
|
||||
const tables: TableInfo[] = [
|
||||
{ name: "users", schema: "public", table_type: "TABLE" },
|
||||
{ name: "orders", schema: "public", table_type: "TABLE" },
|
||||
{ name: "audit_log", schema: "audit", table_type: "TABLE" },
|
||||
];
|
||||
|
||||
describe("parseTableRef", () => {
|
||||
it("parses a bare table before the cursor dot", () => {
|
||||
expect(parseTableRef("SELECT * FROM users.")).toEqual({
|
||||
schema: null,
|
||||
table: "users",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a schema-qualified table", () => {
|
||||
expect(parseTableRef("SELECT * FROM public.users.")).toEqual({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when there is no trailing dot", () => {
|
||||
expect(parseTableRef("SELECT * FROM users WHERE id")).toBeNull();
|
||||
expect(parseTableRef("SELECT")).toBeNull();
|
||||
expect(parseTableRef("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildColumnSuggestions", () => {
|
||||
it("maps columns to column-kind suggestions", () => {
|
||||
const suggestions = buildColumnSuggestions([
|
||||
{ name: "id" },
|
||||
{ name: "email" },
|
||||
]);
|
||||
expect(suggestions).toEqual([
|
||||
{ label: "id", insertText: "id", kind: "column" },
|
||||
{ label: "email", insertText: "email", kind: "column" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getColumnsForTable", () => {
|
||||
beforeEach(() => {
|
||||
mockGetSchemaGraph.mockReset();
|
||||
});
|
||||
|
||||
it("fetches columns from the schema graph and caches them", async () => {
|
||||
mockGetSchemaGraph.mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [{ name: "id" } as never],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
} as never);
|
||||
|
||||
const cols = await getColumnsForTable("c1", "public", "users");
|
||||
expect(cols.map((c) => c.name)).toEqual(["id"]);
|
||||
expect(mockGetSchemaGraph).toHaveBeenCalledTimes(1);
|
||||
|
||||
// cached: a second lookup does not refetch
|
||||
await getColumnsForTable("c1", "public", "users");
|
||||
expect(mockGetSchemaGraph).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns an empty list when the fetch fails", async () => {
|
||||
mockGetSchemaGraph.mockRejectedValue(new Error("boom"));
|
||||
const cols = await getColumnsForTable("c1", "public", "orders");
|
||||
expect(cols).toEqual([]);
|
||||
});
|
||||
|
||||
it("exposes cached columns synchronously", async () => {
|
||||
mockGetSchemaGraph.mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [{ name: "id" } as never],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
} as never);
|
||||
await getColumnsForTable("c1", "public", "users");
|
||||
expect(getCachedColumns("public", "users")?.map((c) => c.name)).toEqual([
|
||||
"id",
|
||||
]);
|
||||
expect(getCachedColumns("public", "missing")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSqlSuggestions", () => {
|
||||
it("includes core SQL keywords", () => {
|
||||
const suggestions = buildSqlSuggestions([], null);
|
||||
const labels = suggestions.map((s) => s.label);
|
||||
for (const kw of ["SELECT", "FROM", "WHERE", "JOIN", "INSERT"]) {
|
||||
expect(labels).toContain(kw);
|
||||
}
|
||||
});
|
||||
|
||||
it("labels keywords as keyword kind", () => {
|
||||
const suggestions = buildSqlSuggestions([], null);
|
||||
const select = suggestions.find((s) => s.label === "SELECT");
|
||||
expect(select?.kind).toBe("keyword");
|
||||
expect(select?.insertText).toBe("SELECT");
|
||||
});
|
||||
|
||||
it("includes table names from the current schema", () => {
|
||||
const suggestions = buildSqlSuggestions(tables, "public");
|
||||
const labels = suggestions.map((s) => s.label);
|
||||
expect(labels).toContain("users");
|
||||
expect(labels).toContain("orders");
|
||||
expect(labels).not.toContain("audit_log");
|
||||
});
|
||||
|
||||
it("includes all tables when no schema is selected", () => {
|
||||
const suggestions = buildSqlSuggestions(tables, null);
|
||||
const labels = suggestions.map((s) => s.label);
|
||||
expect(labels).toContain("audit_log");
|
||||
});
|
||||
|
||||
it("labels tables as table kind", () => {
|
||||
const suggestions = buildSqlSuggestions(tables, "public");
|
||||
const users = suggestions.find((s) => s.label === "users");
|
||||
expect(users?.kind).toBe("table");
|
||||
});
|
||||
|
||||
it("exports a non-empty keyword list", () => {
|
||||
expect(SQL_KEYWORDS.length).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { TableInfo, GraphColumn } from "./types";
|
||||
import { getSchemaGraph } from "./commands";
|
||||
|
||||
export const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"INSERT",
|
||||
"INTO",
|
||||
"VALUES",
|
||||
"UPDATE",
|
||||
"SET",
|
||||
"DELETE",
|
||||
"CREATE",
|
||||
"TABLE",
|
||||
"DROP",
|
||||
"ALTER",
|
||||
"ADD",
|
||||
"COLUMN",
|
||||
"PRIMARY",
|
||||
"KEY",
|
||||
"FOREIGN",
|
||||
"REFERENCES",
|
||||
"INDEX",
|
||||
"UNIQUE",
|
||||
"JOIN",
|
||||
"INNER",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"OUTER",
|
||||
"FULL",
|
||||
"CROSS",
|
||||
"ON",
|
||||
"AS",
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"NULL",
|
||||
"IS",
|
||||
"IN",
|
||||
"EXISTS",
|
||||
"BETWEEN",
|
||||
"LIKE",
|
||||
"ILIKE",
|
||||
"LIMIT",
|
||||
"OFFSET",
|
||||
"ORDER",
|
||||
"BY",
|
||||
"GROUP",
|
||||
"HAVING",
|
||||
"DISTINCT",
|
||||
"UNION",
|
||||
"ALL",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"END",
|
||||
"ASC",
|
||||
"DESC",
|
||||
"WITH",
|
||||
"RETURNING",
|
||||
"BEGIN",
|
||||
"COMMIT",
|
||||
"EXPLAIN",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
] as const;
|
||||
|
||||
export type SqlSuggestionKind = "keyword" | "table" | "column";
|
||||
|
||||
export interface SqlSuggestion {
|
||||
label: string;
|
||||
insertText: string;
|
||||
kind: SqlSuggestionKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the text before the cursor into a `table.` reference. Returns the
|
||||
* table (and optional schema qualifier) when the text ends in a dot right
|
||||
* after an identifier, e.g. `users.` or `public.users.`.
|
||||
*/
|
||||
export function parseTableRef(
|
||||
text: string,
|
||||
): { schema: string | null; table: string } | null {
|
||||
const match = text.match(/(?:(\w+)\.)?(\w+)\.\s*$/);
|
||||
if (!match) return null;
|
||||
return { schema: match[1] ?? null, table: match[2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build column suggestions (used after a `table.` reference).
|
||||
*/
|
||||
export function buildColumnSuggestions(
|
||||
columns: { name: string }[],
|
||||
): SqlSuggestion[] {
|
||||
return columns.map((c) => ({
|
||||
label: c.name,
|
||||
insertText: c.name,
|
||||
kind: "column" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
// Cache of table columns, keyed by "schema.table".
|
||||
const columnCache = new Map<string, GraphColumn[]>();
|
||||
|
||||
/**
|
||||
* Synchronously read cached columns for a table, if the schema graph for its
|
||||
* schema has already been fetched.
|
||||
*/
|
||||
export function getCachedColumns(
|
||||
schema: string,
|
||||
table: string,
|
||||
): GraphColumn[] | undefined {
|
||||
return columnCache.get(`${schema}.${table}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch (and cache) the columns of a table via the schema-graph introspection
|
||||
* command. Caches the whole schema in one round trip. Returns [] on error so
|
||||
* autocomplete degrades gracefully.
|
||||
*/
|
||||
export async function getColumnsForTable(
|
||||
connectionId: string | null,
|
||||
schema: string | null,
|
||||
table: string,
|
||||
): Promise<GraphColumn[]> {
|
||||
const resolvedSchema = schema ?? "public";
|
||||
const key = `${resolvedSchema}.${table}`;
|
||||
const cached = columnCache.get(key);
|
||||
if (cached) return cached;
|
||||
if (!connectionId) return [];
|
||||
|
||||
try {
|
||||
const graph = await getSchemaGraph(connectionId, resolvedSchema);
|
||||
for (const t of graph.tables) {
|
||||
columnCache.set(`${resolvedSchema}.${t.name}`, t.columns);
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return columnCache.get(key) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build editor suggestions for the SQL language: reserved keywords plus the
|
||||
* table names in the currently selected schema.
|
||||
*/
|
||||
export function buildSqlSuggestions(
|
||||
tables: TableInfo[],
|
||||
schema: string | null,
|
||||
): SqlSuggestion[] {
|
||||
const keywords: SqlSuggestion[] = SQL_KEYWORDS.map((kw) => ({
|
||||
label: kw,
|
||||
insertText: kw,
|
||||
kind: "keyword",
|
||||
}));
|
||||
|
||||
const tableNames = new Set(
|
||||
tables
|
||||
.filter((t) => !schema || t.schema === schema)
|
||||
.map((t) => t.name),
|
||||
);
|
||||
const tableSuggestions: SqlSuggestion[] = [...tableNames].map((name) => ({
|
||||
label: name,
|
||||
insertText: name,
|
||||
kind: "table",
|
||||
}));
|
||||
|
||||
return [...keywords, ...tableSuggestions];
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
filterConnections,
|
||||
getDescendantFolderIds,
|
||||
getFolderPathLabel,
|
||||
isDestructiveQuery,
|
||||
pickDefaultSchema,
|
||||
} from "./utils";
|
||||
import type { Connection, Folder, Tag } from "./types";
|
||||
|
||||
@@ -19,6 +21,7 @@ const makeConnection = (over: Partial<Connection> = {}): Connection => ({
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
environment: null,
|
||||
created_at: "2026-07-26T00:00:00Z",
|
||||
updated_at: "2026-07-26T00:00:00Z",
|
||||
...over,
|
||||
@@ -283,6 +286,39 @@ describe("filterConnections", () => {
|
||||
it("search matches tag name", () => {
|
||||
expect(filterConnections(conns, tags, { query: "cache" })).toEqual([conns[1]]);
|
||||
});
|
||||
|
||||
it("matches ANY selected tag (OR semantics)", () => {
|
||||
// c1 has t1, c2 has t2. Selecting both t1+t2 should return BOTH connections.
|
||||
const result = filterConnections(conns, tags, { query: "", activeTagIds: ["t1", "t2"] });
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("matches when connection has only one of multiple selected tags", () => {
|
||||
const c3 = makeConnection({ id: "c3", name: "Cache", host: "cache.local", db_type: "postgresql", tag_ids: ["t1"], folder_id: "f1", environment: "production" });
|
||||
const result = filterConnections([...conns, c3], tags, { query: "", activeTagIds: ["t1", "t2"] });
|
||||
// c3 only has t1 but should still show
|
||||
expect(result.map((c) => c.id)).toContain("c3");
|
||||
});
|
||||
|
||||
it("filters by environment", () => {
|
||||
const c1 = makeConnection({ id: "c1", name: "Prod", db_type: "postgresql", tag_ids: [], environment: "production" });
|
||||
const c2 = makeConnection({ id: "c2", name: "Dev", db_type: "postgresql", tag_ids: [], environment: "development" });
|
||||
const result = filterConnections([c1, c2], tags, { query: "", activeEnvironment: "production" });
|
||||
expect(result).toEqual([c1]);
|
||||
});
|
||||
|
||||
it("activeEnvironment 'none' filters to connections without environment", () => {
|
||||
const c1 = makeConnection({ id: "c1", name: "Prod", db_type: "postgresql", tag_ids: [], environment: "production" });
|
||||
const c2 = makeConnection({ id: "c2", name: "NoEnv", db_type: "postgresql", tag_ids: [], environment: null });
|
||||
const result = filterConnections([c1, c2], tags, { query: "", activeEnvironment: "none" });
|
||||
expect(result).toEqual([c2]);
|
||||
});
|
||||
|
||||
it("environment null/undefined means no filtering", () => {
|
||||
const c1 = makeConnection({ id: "c1", name: "Prod", db_type: "postgresql", tag_ids: [], environment: "production" });
|
||||
const result = filterConnections([c1], tags, { query: "" });
|
||||
expect(result).toEqual([c1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFolderPathLabel", () => {
|
||||
@@ -302,4 +338,80 @@ describe("getFolderPathLabel", () => {
|
||||
it("returns root label for non-existent folder", () => {
|
||||
expect(getFolderPathLabel(folders, "missing")).toBe("Root");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDestructiveQuery", () => {
|
||||
it("returns true for INSERT", () => {
|
||||
expect(isDestructiveQuery("INSERT INTO users VALUES (1)")).toBe(true);
|
||||
});
|
||||
it("returns true for UPDATE", () => {
|
||||
expect(isDestructiveQuery("UPDATE users SET name = 'x'")).toBe(true);
|
||||
});
|
||||
it("returns true for DELETE", () => {
|
||||
expect(isDestructiveQuery("DELETE FROM users")).toBe(true);
|
||||
});
|
||||
it("returns true for DROP", () => {
|
||||
expect(isDestructiveQuery("DROP TABLE users")).toBe(true);
|
||||
});
|
||||
it("returns true for ALTER", () => {
|
||||
expect(isDestructiveQuery("ALTER TABLE users ADD COLUMN age int")).toBe(true);
|
||||
});
|
||||
it("returns true for TRUNCATE", () => {
|
||||
expect(isDestructiveQuery("TRUNCATE TABLE users")).toBe(true);
|
||||
});
|
||||
it("returns true for CREATE", () => {
|
||||
expect(isDestructiveQuery("CREATE TABLE t (id int)")).toBe(true);
|
||||
});
|
||||
it("returns true for REPLACE", () => {
|
||||
expect(isDestructiveQuery("REPLACE INTO users VALUES (1)")).toBe(true);
|
||||
});
|
||||
it("returns false for SELECT", () => {
|
||||
expect(isDestructiveQuery("SELECT * FROM users")).toBe(false);
|
||||
});
|
||||
it("returns false for EXPLAIN", () => {
|
||||
expect(isDestructiveQuery("EXPLAIN SELECT * FROM users")).toBe(false);
|
||||
});
|
||||
it("returns false for WITH (CTE SELECT)", () => {
|
||||
expect(isDestructiveQuery("WITH cte AS (SELECT 1) SELECT * FROM cte")).toBe(false);
|
||||
});
|
||||
it("returns false for SHOW", () => {
|
||||
expect(isDestructiveQuery("SHOW search_path")).toBe(false);
|
||||
});
|
||||
it("strips line comments before checking", () => {
|
||||
expect(isDestructiveQuery("-- harmless comment\nDROP TABLE users")).toBe(true);
|
||||
});
|
||||
it("strips block comments before checking", () => {
|
||||
expect(isDestructiveQuery("/* harmless */ DROP TABLE users")).toBe(true);
|
||||
});
|
||||
it("returns false for empty string", () => {
|
||||
expect(isDestructiveQuery("")).toBe(false);
|
||||
});
|
||||
it("returns false for whitespace only", () => {
|
||||
expect(isDestructiveQuery(" \n\t ")).toBe(false);
|
||||
});
|
||||
it("is case-insensitive", () => {
|
||||
expect(isDestructiveQuery("drop table users")).toBe(true);
|
||||
expect(isDestructiveQuery("Drop Table users")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickDefaultSchema", () => {
|
||||
it("prefers the public schema when available", () => {
|
||||
expect(pickDefaultSchema(["app", "public"])).toBe("public");
|
||||
expect(pickDefaultSchema(["public"])).toBe("public");
|
||||
});
|
||||
|
||||
it("prefers the main schema (SQLite) when available", () => {
|
||||
expect(pickDefaultSchema(["main"])).toBe("main");
|
||||
expect(pickDefaultSchema(["other", "main"])).toBe("main");
|
||||
});
|
||||
|
||||
it("falls back to the first schema when no conventional one exists", () => {
|
||||
expect(pickDefaultSchema(["analytics", "app"])).toBe("analytics");
|
||||
expect(pickDefaultSchema(["zzz"])).toBe("zzz");
|
||||
});
|
||||
|
||||
it("returns null for an empty list", () => {
|
||||
expect(pickDefaultSchema([])).toBeNull();
|
||||
});
|
||||
});
|
||||
+55
-2
@@ -112,16 +112,29 @@ export function getChildFolders(folders: Folder[], parentId: string | null): Fol
|
||||
export function filterConnections(
|
||||
connections: Connection[],
|
||||
tags: Tag[],
|
||||
filter: { query: string; activeTagIds?: string[]; activeDbTypes?: DbType[] },
|
||||
filter: {
|
||||
query: string;
|
||||
activeTagIds?: string[];
|
||||
activeDbTypes?: DbType[];
|
||||
activeEnvironment?: string | null;
|
||||
},
|
||||
): Connection[] {
|
||||
const q = filter.query.trim().toLowerCase();
|
||||
const tagIds = filter.activeTagIds ?? [];
|
||||
const dbTypes = filter.activeDbTypes ?? [];
|
||||
const activeEnvironment = filter.activeEnvironment;
|
||||
const tagNameById = new Map(tags.map((t) => [t.id, t.name.toLowerCase()]));
|
||||
|
||||
return connections.filter((c) => {
|
||||
if (dbTypes.length > 0 && !dbTypes.includes(c.db_type)) return false;
|
||||
if (tagIds.length > 0 && !tagIds.every((id) => c.tag_ids.includes(id))) return false;
|
||||
if (tagIds.length > 0 && !tagIds.some((id) => c.tag_ids.includes(id))) return false;
|
||||
if (activeEnvironment !== undefined && activeEnvironment !== null && activeEnvironment !== "") {
|
||||
if (activeEnvironment === "none") {
|
||||
if (c.environment) return false;
|
||||
} else if (c.environment !== activeEnvironment) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (q.length > 0) {
|
||||
const tagNames = c.tag_ids.map((id) => tagNameById.get(id) ?? "").join(" ");
|
||||
const haystack = `${c.name} ${c.host} ${c.db_type} ${tagNames}`.toLowerCase();
|
||||
@@ -129,4 +142,44 @@ export function filterConnections(
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const DESTRUCTIVE_KEYWORDS = new Set([
|
||||
"INSERT", "UPDATE", "DELETE", "DROP", "ALTER",
|
||||
"TRUNCATE", "CREATE", "REPLACE",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Detect whether `sql` is a data-modifying statement by checking the
|
||||
* first significant keyword after stripping comments and whitespace.
|
||||
*
|
||||
* This is a UX safety net, not a security boundary. The user is already
|
||||
* authenticated to their own database — the confirmation dialog prevents
|
||||
* accidental data loss, not malicious access.
|
||||
*/
|
||||
export function isDestructiveQuery(sql: string): boolean {
|
||||
// Strip block comments /* ... */
|
||||
let stripped = sql.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
// Strip line comments -- ...
|
||||
stripped = stripped.replace(/--[^\n]*/g, " ");
|
||||
// Collapse whitespace
|
||||
const tokens = stripped.trim().split(/\s+/);
|
||||
if (tokens.length === 0 || tokens[0].length === 0) return false;
|
||||
const first = tokens[0].toUpperCase();
|
||||
return DESTRUCTIVE_KEYWORDS.has(first);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the smart default schema for a freshly loaded database.
|
||||
*
|
||||
* Prefers conventional schemas (`public` for PostgreSQL, `main` for SQLite)
|
||||
* and otherwise falls back to the first schema returned by the backend
|
||||
* (which already excludes system schemas and is ordered alphabetically).
|
||||
*/
|
||||
export function pickDefaultSchema(schemas: string[]): string | null {
|
||||
if (schemas.length === 0) return null;
|
||||
for (const preferred of ["public", "main"]) {
|
||||
if (schemas.includes(preferred)) return preferred;
|
||||
}
|
||||
return schemas[0];
|
||||
}
|
||||
Reference in New Issue
Block a user