feat(editor): retain multiple query result runs
This commit is contained in:
1 parent
fddc0fdf50
commit
7f20b03177
26 files changed
+846
-109
No files matched your search
+138
-3
@@ -147,6 +147,81 @@ describe("useSqlExecution", () => {
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(selectedSql, { sourceOffset: selectionFrom });
|
||||
});
|
||||
|
||||
it("forwards execute-in-new-result-tab intent to the query store", async () => {
|
||||
const sql = "SELECT * FROM users";
|
||||
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
|
||||
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
|
||||
const queryStore = useQueryStore();
|
||||
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
|
||||
if (activeTab.value) activeTab.value.result = { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
|
||||
});
|
||||
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
|
||||
|
||||
const execution = useSqlExecution({
|
||||
activeTab: computed(() => activeTab.value),
|
||||
activeConnection: computed(() => activeConnection.value),
|
||||
executableSql: computed(() => sql),
|
||||
activeOutputView,
|
||||
});
|
||||
|
||||
await execution.tryExecuteInNewResultTab();
|
||||
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { openInNewResultTab: true });
|
||||
});
|
||||
|
||||
it("does not record or refresh when a new-result execution restores the prior run", async () => {
|
||||
const result = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
|
||||
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), result });
|
||||
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
|
||||
const queryStore = useQueryStore();
|
||||
vi.spyOn(queryStore, "executeCurrentSql").mockResolvedValue(false);
|
||||
const addHistory = vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
|
||||
const refreshObjects = vi.spyOn(useConnectionStore(), "refreshObjectListTreeNode").mockResolvedValue(undefined);
|
||||
|
||||
const execution = useSqlExecution({
|
||||
activeTab: computed(() => activeTab.value),
|
||||
activeConnection: computed(() => activeConnection.value),
|
||||
executableSql: computed(() => "CREATE TABLE users (id INT)"),
|
||||
activeOutputView,
|
||||
});
|
||||
|
||||
await execution.tryExecuteInNewResultTab();
|
||||
|
||||
expect(addHistory).not.toHaveBeenCalled();
|
||||
expect(refreshObjects).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the new-result-tab intent through SQL parameter input", async () => {
|
||||
const sql = "SELECT * FROM users WHERE id = :id";
|
||||
const resolvedSql = "SELECT * FROM users WHERE id = 7";
|
||||
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
|
||||
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
|
||||
const queryStore = useQueryStore();
|
||||
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
|
||||
if (activeTab.value) activeTab.value.result = { columns: ["id"], rows: [[7]], affected_rows: 0, execution_time_ms: 1 };
|
||||
});
|
||||
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
|
||||
|
||||
const execution = useSqlExecution({
|
||||
activeTab: computed(() => activeTab.value),
|
||||
activeConnection: computed(() => activeConnection.value),
|
||||
executableSql: computed(() => sql),
|
||||
activeOutputView,
|
||||
});
|
||||
|
||||
await execution.tryExecuteInNewResultTab();
|
||||
|
||||
expect(execution.showSqlParameterDialog.value).toBe(true);
|
||||
expect(executeCurrentSql).not.toHaveBeenCalled();
|
||||
|
||||
await execution.onSqlParametersConfirm(resolvedSql);
|
||||
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(resolvedSql, { openInNewResultTab: true });
|
||||
});
|
||||
|
||||
it("sends native SET variables without client-side expansion", async () => {
|
||||
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
|
||||
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
|
||||
@@ -325,7 +400,7 @@ describe("useSqlExecution", () => {
|
||||
expect(addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: "relation does not exist" }));
|
||||
});
|
||||
|
||||
it("keeps the full dangerous script pending and executes it unchanged after confirmation", async () => {
|
||||
it("keeps the full dangerous script and new-result-tab intent through confirmation", async () => {
|
||||
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
|
||||
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
|
||||
@@ -343,7 +418,7 @@ describe("useSqlExecution", () => {
|
||||
activeOutputView,
|
||||
});
|
||||
|
||||
await execution.tryExecute();
|
||||
await execution.tryExecuteInNewResultTab();
|
||||
|
||||
expect(execution.showDangerDialog.value).toBe(true);
|
||||
expect(execution.pendingDangerSql.value).toBe(sql);
|
||||
@@ -351,7 +426,67 @@ describe("useSqlExecution", () => {
|
||||
|
||||
await execution.onDangerConfirm();
|
||||
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { openInNewResultTab: true });
|
||||
});
|
||||
|
||||
it("keeps the active retained result when a new-result dangerous prompt is cancelled", async () => {
|
||||
const result = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
|
||||
const activeTab = ref<QueryTab | undefined>({
|
||||
...queryTab("app"),
|
||||
result,
|
||||
resultRuns: [{ id: "run-1", title: "Run 1", sequence: 1, sql: "SELECT 1", createdAt: 1, result }],
|
||||
activeResultRunId: "run-1",
|
||||
});
|
||||
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
|
||||
const queryStore = useQueryStore();
|
||||
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql");
|
||||
|
||||
const execution = useSqlExecution({
|
||||
activeTab: computed(() => activeTab.value),
|
||||
activeConnection: computed(() => activeConnection.value),
|
||||
executableSql: computed(() => "DROP TABLE users"),
|
||||
activeOutputView,
|
||||
});
|
||||
|
||||
await execution.tryExecuteInNewResultTab();
|
||||
expect(execution.showDangerDialog.value).toBe(true);
|
||||
|
||||
execution.showDangerDialog.value = false;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(executeCurrentSql).not.toHaveBeenCalled();
|
||||
expect(activeTab.value?.activeResultRunId).toBe("run-1");
|
||||
expect(activeTab.value?.result).toEqual(result);
|
||||
});
|
||||
|
||||
it("keeps the new-result-tab intent through Redis command confirmation", async () => {
|
||||
const sql = "DEL user:1";
|
||||
const activeTab = ref<QueryTab | undefined>({ ...queryTab("0"), sql });
|
||||
const activeConnection = ref<ConnectionConfig | undefined>(connection("redis"));
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
|
||||
const queryStore = useQueryStore();
|
||||
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
|
||||
if (activeTab.value) activeTab.value.result = { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 };
|
||||
});
|
||||
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
|
||||
|
||||
const execution = useSqlExecution({
|
||||
activeTab: computed(() => activeTab.value),
|
||||
activeConnection: computed(() => activeConnection.value),
|
||||
executableSql: computed(() => sql),
|
||||
activeOutputView,
|
||||
blockDangerousRedisCommands: ref(true),
|
||||
});
|
||||
|
||||
await execution.tryExecuteInNewResultTab();
|
||||
|
||||
expect(execution.showDangerDialog.value).toBe(true);
|
||||
expect(executeCurrentSql).not.toHaveBeenCalled();
|
||||
|
||||
await execution.onDangerConfirm();
|
||||
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { skipRedisSafetyCheck: false, openInNewResultTab: true });
|
||||
});
|
||||
|
||||
it("requires production confirmation even when ordinary danger prompts are disabled", async () => {
|
||||
|
||||
@@ -22,6 +22,10 @@ import type { ConnectionConfig, DatabaseType, QueryTab } from "@/types/database"
|
||||
|
||||
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
|
||||
|
||||
interface SqlExecutionOptions {
|
||||
openInNewResultTab?: boolean;
|
||||
}
|
||||
|
||||
export function stripSqlComments(sql: string): string {
|
||||
return sql
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
@@ -98,6 +102,7 @@ export function useSqlExecution(deps: {
|
||||
const pendingSourceOffset = ref<number | undefined>();
|
||||
const pendingDangerKind = ref<"sql" | "redis">("sql");
|
||||
const pendingDangerSourceOffset = ref<number | undefined>();
|
||||
const pendingOpenInNewResultTab = ref(false);
|
||||
|
||||
async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<{ sql: string; sourceOffset?: number }> {
|
||||
const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, deps.activeConnection.value?.db_type).atSet;
|
||||
@@ -112,7 +117,7 @@ export function useSqlExecution(deps: {
|
||||
return { sql, sourceOffset: source.selectionFrom + leadingWhitespace };
|
||||
}
|
||||
|
||||
async function tryExecute(sqlOverride?: SqlExecutionOverride) {
|
||||
async function tryExecute(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
|
||||
const tab = deps.activeTab.value;
|
||||
const { sql, sourceOffset } = await resolvedExecutableSql(sqlOverride);
|
||||
if (!tab || !sql.trim()) return;
|
||||
@@ -120,11 +125,15 @@ export function useSqlExecution(deps: {
|
||||
deps.onMissingDatabase?.();
|
||||
return;
|
||||
}
|
||||
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset)) return;
|
||||
await continueExecute(sql, sourceOffset);
|
||||
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, options)) return;
|
||||
await continueExecute(sql, sourceOffset, options);
|
||||
}
|
||||
|
||||
async function continueExecute(sql: string, sourceOffset?: number) {
|
||||
function tryExecuteInNewResultTab(sqlOverride?: SqlExecutionOverride) {
|
||||
return tryExecute(sqlOverride, { openInNewResultTab: true });
|
||||
}
|
||||
|
||||
async function continueExecute(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
|
||||
// Redis: block dangerous commands when toggle is on (scan entire batch for highest safety level)
|
||||
if (deps.activeConnection.value?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) {
|
||||
const commands = sql
|
||||
@@ -151,6 +160,7 @@ export function useSqlExecution(deps: {
|
||||
pendingDangerSql.value = sql;
|
||||
pendingDangerKind.value = "redis";
|
||||
pendingDangerSourceOffset.value = sourceOffset;
|
||||
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
|
||||
suppressDangerConfirm.value = false;
|
||||
showDangerDialog.value = true;
|
||||
return;
|
||||
@@ -166,7 +176,7 @@ export function useSqlExecution(deps: {
|
||||
productionDatabases: productionAssessment.databases,
|
||||
source: t("production.sourceSqlEditor"),
|
||||
});
|
||||
if (confirmed) await doExecute(sql, sourceOffset);
|
||||
if (confirmed) await doExecute(sql, sourceOffset, options);
|
||||
return;
|
||||
}
|
||||
if (isDangerousSql(sql, deps.activeConnection.value?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
|
||||
@@ -174,14 +184,15 @@ export function useSqlExecution(deps: {
|
||||
pendingDangerSql.value = sql;
|
||||
pendingDangerKind.value = "sql";
|
||||
pendingDangerSourceOffset.value = sourceOffset;
|
||||
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
|
||||
suppressDangerConfirm.value = false;
|
||||
showDangerDialog.value = true;
|
||||
} else {
|
||||
await doExecute(sql, sourceOffset);
|
||||
await doExecute(sql, sourceOffset, options);
|
||||
}
|
||||
}
|
||||
|
||||
function prepareSqlParameterDialog(sql: string, sourceOffset?: number): boolean {
|
||||
function prepareSqlParameterDialog(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}): boolean {
|
||||
const databaseType = deps.activeConnection.value?.db_type;
|
||||
const toggles = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, databaseType);
|
||||
const enabledSyntaxes = enabledSqlParameterSyntaxes(toggles);
|
||||
@@ -192,11 +203,12 @@ export function useSqlExecution(deps: {
|
||||
sqlParameterDatabaseType.value = databaseType;
|
||||
sqlParameterEnabledSyntaxes.value = enabledSyntaxes;
|
||||
pendingSourceOffset.value = sourceOffset;
|
||||
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
|
||||
showSqlParameterDialog.value = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function doExecute(sql?: string, sourceOffset?: number) {
|
||||
async function doExecute(sql?: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
|
||||
if (sql === undefined) ({ sql, sourceOffset } = await resolvedExecutableSql());
|
||||
const tab = deps.activeTab.value;
|
||||
if (!tab || !sql.trim()) return;
|
||||
@@ -210,10 +222,12 @@ export function useSqlExecution(deps: {
|
||||
const connName = executionConnection?.name || "";
|
||||
const start = Date.now();
|
||||
const isRedis = executionDatabaseType === "redis";
|
||||
await queryStore.executeCurrentSql(sql, {
|
||||
const producedResult = await queryStore.executeCurrentSql(sql, {
|
||||
...(isRedis ? { skipRedisSafetyCheck: deps.blockDangerousRedisCommands?.value === false } : {}),
|
||||
...(sourceOffset !== undefined ? { sourceOffset } : {}),
|
||||
...(options.openInNewResultTab ? { openInNewResultTab: true } : {}),
|
||||
});
|
||||
if (producedResult === false) return;
|
||||
if (tab.result && !tab.result.columns.length && !tab.results?.some((result) => result.columns.length > 0)) {
|
||||
deps.activeOutputView.value = "summary";
|
||||
}
|
||||
@@ -278,17 +292,20 @@ export function useSqlExecution(deps: {
|
||||
const sql = pendingDangerSql.value;
|
||||
const sourceOffset = pendingDangerSourceOffset.value;
|
||||
const kind = pendingDangerKind.value;
|
||||
const openInNewResultTab = pendingOpenInNewResultTab.value;
|
||||
pendingDangerSql.value = "";
|
||||
pendingDangerSourceOffset.value = undefined;
|
||||
pendingDangerKind.value = "sql";
|
||||
pendingOpenInNewResultTab.value = false;
|
||||
if (suppressDangerConfirm.value && kind === "sql") {
|
||||
settingsStore.updateEditorSettings({ confirmDangerousSqlExecution: false });
|
||||
}
|
||||
suppressDangerConfirm.value = false;
|
||||
await doExecute(sql, sourceOffset);
|
||||
await doExecute(sql, sourceOffset, { openInNewResultTab });
|
||||
}
|
||||
|
||||
async function onSqlParametersConfirm(sql: string) {
|
||||
const openInNewResultTab = pendingOpenInNewResultTab.value;
|
||||
showSqlParameterDialog.value = false;
|
||||
sqlParameterSourceSql.value = "";
|
||||
sqlParameterNames.value = [];
|
||||
@@ -296,7 +313,8 @@ export function useSqlExecution(deps: {
|
||||
sqlParameterEnabledSyntaxes.value = [];
|
||||
const sourceOffset = pendingSourceOffset.value;
|
||||
pendingSourceOffset.value = undefined;
|
||||
await continueExecute(sql, sourceOffset);
|
||||
pendingOpenInNewResultTab.value = false;
|
||||
await continueExecute(sql, sourceOffset, { openInNewResultTab });
|
||||
}
|
||||
|
||||
watch(showSqlParameterDialog, (open) => {
|
||||
@@ -306,6 +324,7 @@ export function useSqlExecution(deps: {
|
||||
sqlParameterDatabaseType.value = undefined;
|
||||
sqlParameterEnabledSyntaxes.value = [];
|
||||
pendingSourceOffset.value = undefined;
|
||||
pendingOpenInNewResultTab.value = false;
|
||||
});
|
||||
|
||||
watch(showDangerDialog, (open) => {
|
||||
@@ -313,6 +332,7 @@ export function useSqlExecution(deps: {
|
||||
pendingDangerSql.value = "";
|
||||
pendingDangerSourceOffset.value = undefined;
|
||||
pendingDangerKind.value = "sql";
|
||||
pendingOpenInNewResultTab.value = false;
|
||||
suppressDangerConfirm.value = false;
|
||||
});
|
||||
|
||||
@@ -322,6 +342,7 @@ export function useSqlExecution(deps: {
|
||||
showDangerDialog,
|
||||
suppressDangerConfirm,
|
||||
tryExecute,
|
||||
tryExecuteInNewResultTab,
|
||||
doExecute,
|
||||
cancelActiveExecution,
|
||||
tryExplain,
|
||||
|
||||
Reference in new issue
Block a user