From 4644015201ffb6ef07db6d58381e99369acfb43e Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 17 Sep 2026 02:15:13 +0800 Subject: [PATCH] fix(mysql): strip CLI \G suffix before executing queries --- .../__tests__/sql/sqlStatementRanges.spec.ts | 21 +++++++++++++++- .../desktop/src/lib/sql/sqlStatementRanges.ts | 16 ++++++++++++ .../queryStore.mysqlGroupedEditing.spec.ts | 8 ++++++ .../queryStore.queryResultExport.spec.ts | 25 +++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 20 +++++++++------ 5 files changed, 82 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts index 013f3294f8..db13718be7 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlStatementRanges.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { executionCandidateForMode } from "@/lib/sql/sqlExecutionTarget"; -import { buildExecutionCandidates, currentExecutableStatementRange, executableStatementRanges, fullSqlRange, hasMultipleExecutionTargets, splitSqlStatementRanges, statementRangeAtCursor, supportsExecutionTargetPicker } from "@/lib/sql/sqlStatementRanges"; +import { buildExecutionCandidates, currentExecutableStatementRange, executableStatementRanges, fullSqlRange, hasMultipleExecutionTargets, splitSqlStatementRanges, statementRangeAtCursor, stripMysqlClientDisplayCommand, supportsExecutionTargetPicker } from "@/lib/sql/sqlStatementRanges"; function indexOf(sql: string, needle: string, occurrence = 1): number { let from = 0; @@ -377,6 +377,25 @@ BEGIN END; SELECT 3 FROM DUMMY;`; +describe("stripMysqlClientDisplayCommand", () => { + it("removes a trailing MySQL CLI vertical-output command", () => { + expect(stripMysqlClientDisplayCommand("SHOW CREATE FUNCTION fun_grade \\G")).toBe("SHOW CREATE FUNCTION fun_grade"); + expect(stripMysqlClientDisplayCommand("SHOW CREATE FUNCTION fun_grade \\G;")).toBe("SHOW CREATE FUNCTION fun_grade;"); + expect(stripMysqlClientDisplayCommand("SELECT 1\\G\n")).toBe("SELECT 1\n"); + }); + + it("removes the lowercase terminator and a command following the semicolon", () => { + expect(stripMysqlClientDisplayCommand("SHOW STATUS \\g")).toBe("SHOW STATUS"); + expect(stripMysqlClientDisplayCommand("SELECT 1; \\G")).toBe("SELECT 1;"); + }); + + it("does not rewrite ordinary SQL or a command inside a line comment", () => { + expect(stripMysqlClientDisplayCommand("SELECT '\\G'")).toBe("SELECT '\\G'"); + expect(stripMysqlClientDisplayCommand("SELECT 1 -- keep \\G")).toBe("SELECT 1 -- keep \\G"); + expect(stripMysqlClientDisplayCommand("SELECT 1 # keep \\G")).toBe("SELECT 1 # keep \\G"); + }); +}); + describe("splitSqlStatementRanges", () => { it("splits multiple top-level statements", () => { const sql = "SELECT 1;\nSELECT 2;\nSELECT 3;"; diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts index 67d115b203..1659d2c0d4 100644 --- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts +++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts @@ -32,6 +32,22 @@ export function supportsExecutionTargetPicker(databaseType?: DatabaseType): bool return !!databaseType && (databaseType === "redis" || isHttpJsonRestDatabaseType(databaseType) || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType)); } +/** Remove the MySQL CLI's trailing vertical-output command before execution. */ +export function stripMysqlClientDisplayCommand(sql: string): string { + const trimmed = sql.trimEnd(); + const hasTrailingSemicolon = trimmed.endsWith(";"); + const withoutTrailingSemicolon = hasTrailingSemicolon ? trimmed.slice(0, -1).trimEnd() : trimmed; + if (!withoutTrailingSemicolon.endsWith("\\G") && !withoutTrailingSemicolon.endsWith("\\g")) return sql; + + const markerStart = withoutTrailingSemicolon.length - 2; + const lineStart = withoutTrailingSemicolon.lastIndexOf("\n", markerStart - 1) + 1; + const linePrefix = withoutTrailingSemicolon.slice(lineStart, markerStart); + if (linePrefix.includes("--") || linePrefix.includes("#")) return sql; + + const executableSql = withoutTrailingSemicolon.slice(0, markerStart).trimEnd(); + return `${executableSql}${hasTrailingSemicolon ? ";" : ""}${sql.slice(trimmed.length)}`; +} + export function hasMultipleExecutionTargets(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean { if (databaseType === "redis") { return redisExecutableCommandCount(sql) > 1; diff --git a/apps/desktop/src/stores/__tests__/queryStore.mysqlGroupedEditing.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.mysqlGroupedEditing.spec.ts index 6fbffacaee..2cd1ebe3aa 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.mysqlGroupedEditing.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.mysqlGroupedEditing.spec.ts @@ -106,6 +106,14 @@ describe("queryStore MySQL grouped-result editing", () => { expect(listIndexes).not.toHaveBeenCalled(); }); + it("strips the MySQL CLI vertical-output suffix only from the submitted SQL", async () => { + const sourceSql = "SHOW CREATE FUNCTION fun_grade \\G;"; + const tab = await executeGrouped(sourceSql, ["Create Function"]); + + expect(executeMulti.mock.calls[0]?.[2]).toBe("SHOW CREATE FUNCTION fun_grade;"); + expect(tab.lastExecutedSql).toBe(sourceSql); + }); + it("identifies the FROM root independently of metadata completion order", async () => { getColumns.mockImplementation(async (_connectionId: string, _database: string, _schema: string, table: string) => { if (table === "users") await new Promise((resolve) => setTimeout(resolve, 10)); diff --git a/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts index dcf45bffe3..b91614bc0c 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts @@ -132,4 +132,29 @@ describe("queryStore query result export", () => { expect(request?.useAgentCursor).toBe(true); }); + + it("strips the MySQL CLI vertical-output suffix from export re-execution SQL", async () => { + mocks.getConfig.mockReturnValue({ id: "mysql-1", name: "MySQL", db_type: "mysql", database: "app", query_timeout_secs: 30 }); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query"); + const tab = store.tabs.find((item) => item.id === tabId)!; + tab.sql = "SHOW CREATE FUNCTION fun_grade \\G"; + tab.lastExecutedSql = tab.sql; + tab.result = { + columns: ["Create Function"], + rows: [["definition"]], + affected_rows: 0, + execution_time_ms: 1, + }; + + const request = await store.buildQueryResultExportRequest(tabId, { + exportId: "export-g", + filePath: "fun.csv", + format: "csv", + }); + + expect(request?.sql).toBe("SHOW CREATE FUNCTION fun_grade"); + expect(request?.queryBaseSql).toBe("SHOW CREATE FUNCTION fun_grade"); + }); }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 5c4af99290..5e0ff7aa94 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -59,7 +59,7 @@ import { appendLargeValueCells, canUseTableDataLargeValuePreview, remapLargeValu import { simpleDataGridOrderByReferencesMissingColumn, sortDataGridRowIndexes, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; import { agentProtocolQueryResultMaxRows, capQueryResultTotal, effectiveQueryResultMaxRows, limitQueryPagination, queryResultLimitReached } from "@/lib/dataGrid/queryResultRowLimit"; -import { elasticsearchRestRequestRanges, executableStatementRanges, splitSqlStatementRanges, sqlStatementParameterOptionsForCompatibility } from "@/lib/sql/sqlStatementRanges"; +import { elasticsearchRestRequestRanges, executableStatementRanges, splitSqlStatementRanges, sqlStatementParameterOptionsForCompatibility, stripMysqlClientDisplayCommand } from "@/lib/sql/sqlStatementRanges"; import type { SqlParameterOptions } from "@/lib/sql/sqlParameters"; import { replaceSqlServerLeadingUseQuery, sqlServerLeadingUseScript, sqlServerUseDatabaseFromStatement } from "@/lib/sql/sqlCompletionLookupTarget"; import { classifySqlRisk } from "@/lib/sql/sqlRisk"; @@ -6241,6 +6241,9 @@ export const useQueryStore = defineStore("query", () => { mongoCommands = splitMongoCommandRanges(sql); } const effectiveDbType = effectiveDatabaseTypeForConnection(conn); + if (effectiveDbType === "mysql") { + sqlToExecute = stripMysqlClientDisplayCommand(sqlToExecute); + } if (tab.autoCommit === false && !supportsTransaction(conn?.db_type)) { tab.autoCommit = true; } @@ -8291,12 +8294,13 @@ export const useQueryStore = defineStore("query", () => { await connStore.ensureConnected(location.connectionId); const conn = connStore.getConfig(location.connectionId); const effectiveDbType = effectiveDatabaseTypeForConnection(conn); + const executableSql = effectiveDbType === "mysql" ? stripMysqlClientDisplayCommand(sql) : sql; const executionDatabase = dataTabExecutionDatabase(conn, location.database, location.catalog); // main 引入全局查询超时:queryTimeoutSecsForConnection 现需传入全局默认值; // settingsStore 取 defineStore 顶层声明的实例(本函数无局部覆盖)。 const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs); const useAgentCursor = usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile); - const queryBaseSql = queryResultBaseSql(tab); + const queryBaseSql = effectiveDbType === "mysql" ? stripMysqlClientDisplayCommand(queryResultBaseSql(tab)) : queryResultBaseSql(tab); const exportSettings = useSettingsStore().editorSettings; const exportRowLimit = exportSettings.exportRowLimitEnabled ? exportSettings.exportRowLimit : Number.POSITIVE_INFINITY; @@ -8375,7 +8379,7 @@ export const useQueryStore = defineStore("query", () => { const remaining = exportRowLimit - rows.length; const effectivePageLimit = Math.min(pageLimit, remaining); const plan = await api.prepareQueryPaginationExecutionPlan({ - sql, + sql: executableSql, queryBaseSql, databaseType: effectiveDbType, pagination: { limit: effectivePageLimit, offset, sessionId }, @@ -8437,7 +8441,8 @@ export const useQueryStore = defineStore("query", () => { if (!effectiveDbType) return undefined; if (effectiveDbType === "mongodb") return undefined; const useAgentCursor = usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile); - const queryBaseSql = queryResultBaseSql(tab); + const executableSql = effectiveDbType === "mysql" ? stripMysqlClientDisplayCommand(sql) : sql; + const queryBaseSql = effectiveDbType === "mysql" ? stripMysqlClientDisplayCommand(queryResultBaseSql(tab)) : queryResultBaseSql(tab); const resultStatementIndex = tab.result.statement_index; const batchSql = tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql; const batchStatements = effectiveDbType === "postgres" && tab.result.truncated === true && Number.isInteger(resultStatementIndex) && resultStatementIndex! > 0 ? splitSqlStatementRanges(batchSql, effectiveDbType) : []; @@ -8452,7 +8457,7 @@ export const useQueryStore = defineStore("query", () => { database: dataTabExecutionDatabase(conn, location.database, location.catalog), schema: location.schema, catalog: location.catalog, - sql, + sql: executableSql, queryBaseSql, setupSql, databaseType: effectiveDbType, @@ -8488,13 +8493,14 @@ export const useQueryStore = defineStore("query", () => { if (!effectiveDbType) return; const exportId = uuid(); + const executableSql = effectiveDbType === "mysql" ? stripMysqlClientDisplayCommand(sql) : sql; const request: api.QueryResultExportRequest = { exportId, connectionId: tab.connectionId, database: tab.database, schema: tab.schema, - sql, - queryBaseSql: sql, + sql: executableSql, + queryBaseSql: executableSql, databaseType: effectiveDbType, useAgentCursor: usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile), filePath,