From 422bd931fc3041a083c792b6b24eff34ad6cb210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=9D=E6=97=A5?= <40729244+SumilerJR@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:36:36 +0800 Subject: [PATCH] feat(grid): copy query SQL without ordinary comments --- apps/desktop/src/components/grid/DataGrid.vue | 35 +++++++--- apps/desktop/src/i18n/locales/az.ts | 2 + apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/tr.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../sql/sqlWithoutCommentsForCopy.spec.ts | 68 +++++++++++++++++++ .../src/lib/sql/sqlWithoutCommentsForCopy.ts | 38 +++++++++++ 13 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/sql/sqlWithoutCommentsForCopy.spec.ts create mode 100644 apps/desktop/src/lib/sql/sqlWithoutCommentsForCopy.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 8b039a1ffb..5766435107 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -230,6 +230,7 @@ import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText"; import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi"; import { buildDataGridSavedRowRefreshPlan, dataGridSavedRowRefreshPatches } from "@/lib/dataGrid/dataGridSavedRowRefresh"; import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis"; +import { sqlWithoutCommentsForCopy } from "@/lib/sql/sqlWithoutCommentsForCopy"; import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; import { @@ -10193,6 +10194,16 @@ async function copyUserFacingSql() { if (userFacingSql.value) await copyText(userFacingSql.value); } +async function copyUserFacingSqlWithoutComments() { + if (!userFacingSql.value) return; + await copyText(sqlWithoutCommentsForCopy(userFacingSql.value, resolvedDatabaseType.value)); +} + +const statusSqlContextMenuItems = computed(() => [ + { label: t("grid.copyQuerySql"), icon: Copy, action: () => void copyUserFacingSql() }, + { label: t("grid.copyExecutableSql"), icon: Copy, action: () => void copyUserFacingSqlWithoutComments() }, +]); + type TableInfoTabItem = { id: TableInfoTab; label: string; @@ -13559,16 +13570,20 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val - - - - {{ sqlOneLiner }} - - - -
{{ userFacingSql }}
-
-
+
+ + + + + {{ sqlOneLiner }} + + + +
{{ userFacingSql }}
+
+
+
+
{ + it("strips commented-out predicates and blank lines left behind", () => { + const sql = `select * +from ( + select 1 as id, 'alpha' as keyword +) demo_tasks +where 1=1 +-- and id in (42) +and keyword in ('alpha') +-- and keyword like "%beta%" +-- and env = 'prod' +-- and create_time > '2020-01-01' +order by id desc`; + + expect(sqlWithoutCommentsForCopy(sql, "mysql")).toBe(`select * +from ( + select 1 as id, 'alpha' as keyword +) demo_tasks +where 1=1 +and keyword in ('alpha') +order by id desc`); + }); + + it("keeps -- and # that sit inside string literals", () => { + expect(sqlWithoutCommentsForCopy("select '-- not comment', '# also not' from t -- drop", "mysql")).toBe("select '-- not comment', '# also not' from t"); + }); + + it("strips MySQL # line comments", () => { + expect(sqlWithoutCommentsForCopy("select 1 # note\nfrom t", "mysql")).toBe("select 1\nfrom t"); + }); + + it("does not treat PostgreSQL #> as a hash comment", () => { + expect(sqlWithoutCommentsForCopy("select data #> '{a}' -- note\nfrom t", "postgres")).toBe("select data #> '{a}'\nfrom t"); + }); + + it("keeps optimizer hints and MySQL executable comments", () => { + expect(sqlWithoutCommentsForCopy("select /*+ INDEX(t i) */ id from t /* docs */", "oracle")).toBe("select /*+ INDEX(t i) */ id from t"); + expect(sqlWithoutCommentsForCopy("select /*!40101 1 */ from t -- skip", "mysql")).toBe("select /*!40101 1 */ from t"); + }); + + it("returns empty input unchanged", () => { + expect(sqlWithoutCommentsForCopy("")).toBe(""); + }); + + it.each([ + ["SELECT/* note */1", "SELECT 1"], + ["SELECT id/* note */FROM items", "SELECT id FROM items"], + ["SELECT 1-/* note */-2", "SELECT 1- -2"], + ])("preserves token boundaries in %s", (sql, expected) => { + expect(sqlWithoutCommentsForCopy(sql, "postgres")).toBe(expected); + }); + + it.each(["SELECT 'line1\n\nline2'", "SELECT 'line1 \nline2'", "SELECT $$line1\n\nline2$$", 'SELECT "line1 \n\nline2" FROM items', "SELECT 'unfinished literal \n "])("preserves whitespace inside non-comment tokens in %s", (sql) => { + expect(sqlWithoutCommentsForCopy(sql, "postgres")).toBe(sql); + }); + + it("removes only exterior blank lines and preserves CRLF separators", () => { + expect(sqlWithoutCommentsForCopy("SELECT 1\r\n-- note\r\n\r\nFROM items", "postgres")).toBe("SELECT 1\r\nFROM items"); + }); + + it("preserves multiline executable comments and optimizer hints", () => { + const sql = "SELECT /*!40101 1\n\n + 2 */ /*+\n\n INDEX(items idx) */ FROM items"; + expect(sqlWithoutCommentsForCopy(sql, "mysql")).toBe(sql); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlWithoutCommentsForCopy.ts b/apps/desktop/src/lib/sql/sqlWithoutCommentsForCopy.ts new file mode 100644 index 0000000000..24f3e9c199 --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlWithoutCommentsForCopy.ts @@ -0,0 +1,38 @@ +import { BACKSLASH_ESCAPE_STRING_DIALECTS } from "@/lib/sql/sqlStatementRanges"; +import { resolveSqlDialectId } from "@/lib/sql/semantic/dialect"; +import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens"; +import type { DatabaseType } from "@/types/database"; + +function keepSqlComment(text: string): boolean { + // Optimizer hints and MySQL executable comments are sent to the engine. + return text.startsWith("/*+") || text.startsWith("/*!"); +} + +function collapseBlankLines(whitespace: string): string { + return whitespace.replace(/[ \t]+(?=\r?\n)/g, "").replace(/(\r?\n)(?:\r?\n)+/g, "$1"); +} + +export function sqlWithoutCommentsForCopy(sql: string, databaseType?: DatabaseType): string { + if (!sql) return ""; + const dialectId = resolveSqlDialectId({ databaseType }); + const mysqlLike = dialectId === "mysql" || dialectId === "doris"; + const tokens = tokenizeSqlSemantic(sql, dialectId, { + mysqlDashCommentRequiresWhitespace: mysqlLike, + mysqlBackslashEscape: !!databaseType && BACKSLASH_ESCAPE_STRING_DIALECTS.has(databaseType), + }); + + let output = ""; + let cursor = 0; + let whitespace = ""; + for (const token of tokens) { + whitespace += sql.slice(cursor, token.span.start); + cursor = token.span.end; + if (token.kind === "comment" && !keepSqlComment(token.text)) { + whitespace += token.text.replace(/[^\r\n]/g, "") || " "; + continue; + } + output += (output ? collapseBlankLines(whitespace) : "") + token.text; + whitespace = ""; + } + return output; +}