feat(grid): copy query SQL without ordinary comments

This commit is contained in:
九日 authored and GitHub committed 2026-09-18 01:36:36 +08:00
1 parent 4cdf18d652
commit 422bd931fc
13 files changed
+151 -10

No files matched your search

+25 -10
View File
@@ -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<ContextMenuItem[]>(() => [
{ 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
</template>
</div>
<Tooltip v-if="sqlOneLiner">
<TooltipTrigger as-child>
<span class="min-w-0 max-w-full justify-self-center truncate opacity-60 cursor-pointer hover:opacity-100" @click="copyUserFacingSql">
{{ sqlOneLiner }}
</span>
</TooltipTrigger>
<TooltipContent side="top" class="max-w-md">
<pre class="text-xs font-mono whitespace-pre-wrap">{{ userFacingSql }}</pre>
</TooltipContent>
</Tooltip>
<div v-if="sqlOneLiner" class="min-w-0 max-w-full justify-self-center">
<CustomContextMenu :items="statusSqlContextMenuItems" v-slot="{ onContextMenu }">
<Tooltip>
<TooltipTrigger as-child>
<span class="block min-w-0 max-w-full truncate opacity-60 cursor-pointer hover:opacity-100" data-result-sql @click="copyUserFacingSql" @contextmenu="onContextMenu">
{{ sqlOneLiner }}
</span>
</TooltipTrigger>
<TooltipContent side="top" class="max-w-md">
<pre class="text-xs font-mono whitespace-pre-wrap">{{ userFacingSql }}</pre>
</TooltipContent>
</Tooltip>
</CustomContextMenu>
</div>
<span v-else class="min-w-0" />
<DataGridPagination
+2
View File
@@ -2259,6 +2259,8 @@ export default withEnglishFallback({
noAlterSqlAvailable: "Bu sütun üçün ALTER SQL mövcud deyil",
copyAlterSqlFailed: "Kopyalamaq mümkün olmadı: {message}",
copySqlCondition: "SQL şərtini kopyala",
copyQuerySql: "SQL-i kopyala",
copyExecutableSql: "SQL-i kopyala (şərhlərsiz)",
transpose: "Sətri transponə et",
transposeMultiRowToggle: "Çoxsətirli",
transposeSingleRow: "Cari sətir",
+2
View File
@@ -2417,6 +2417,8 @@ export default {
noAlterSqlAvailable: "No ALTER SQL available for this column",
copyAlterSqlFailed: "Copy failed: {message}",
copySqlCondition: "Copy SQL Condition",
copyQuerySql: "Copy SQL",
copyExecutableSql: "Copy SQL (without comments)",
transpose: "Transpose Row",
transposeMultiRowToggle: "Multi-row",
transposeSingleRow: "Current row",
+2
View File
@@ -2241,6 +2241,8 @@ export default withEnglishFallback({
copyColumnName: "Copiar nombre de columna",
columnDragChipMany: "{names} ({count} columnas)",
copySqlCondition: "Copiar condición SQL",
copyQuerySql: "Copiar SQL",
copyExecutableSql: "Copiar SQL (sin comentarios)",
copyAlterColumnSql: "Copiar como SQL ALTER",
alterSqlCopied: "SQL ALTER copiado al portapapeles",
alterSqlCopiedWithWarnings: "SQL ALTER copiado al portapapeles ({count} advertencia(s))",
+2
View File
@@ -2244,6 +2244,8 @@ export default withEnglishFallback({
noAlterSqlAvailable: "Nessun SQL ALTER disponibile per questa colonna",
copyAlterSqlFailed: "Copia non riuscita: {message}",
copySqlCondition: "Copia Condizione SQL",
copyQuerySql: "Copia SQL",
copyExecutableSql: "Copia SQL (senza commenti)",
transpose: "Traspone Riga",
transposeMultiRowToggle: "Più righe",
transposeSingleRow: "Riga corrente",
+2
View File
@@ -2256,6 +2256,8 @@ export default withEnglishFallback({
noAlterSqlAvailable: "この列に利用可能なALTER SQLはありません",
copyAlterSqlFailed: "コピーに失敗しました: {message}",
copySqlCondition: "SQL条件をコピー",
copyQuerySql: "SQLをコピー",
copyExecutableSql: "SQLをコピー(コメントなし)",
transpose: "行を転置",
transposeMultiRowToggle: "複数行",
transposeSingleRow: "現在の行",
+2
View File
@@ -2238,6 +2238,8 @@ export default withEnglishFallback({
noAlterSqlAvailable: "이 컬럼에 사용 가능한 ALTER SQL이 없습니다",
copyAlterSqlFailed: "복사 실패: {message}",
copySqlCondition: "SQL 조건 복사",
copyQuerySql: "SQL 복사",
copyExecutableSql: "SQL 복사 (주석 제외)",
transpose: "행 전치",
transposeMultiRowToggle: "다중 행",
transposeSingleRow: "현재 행",
+2
View File
@@ -2246,6 +2246,8 @@ export default withEnglishFallback({
noAlterSqlAvailable: "Nenhum SQL ALTER disponível para esta coluna",
copyAlterSqlFailed: "Falha ao copiar: {message}",
copySqlCondition: "Copiar Condição SQL",
copyQuerySql: "Copiar SQL",
copyExecutableSql: "Copiar SQL (sem comentários)",
transpose: "Transpor Linha",
transposeMultiRowToggle: "Várias linhas",
transposeSingleRow: "Linha atual",
+2
View File
@@ -2254,6 +2254,8 @@ export default withEnglishFallback({
noAlterSqlAvailable: "Bu sütun için ALTER SQL yok",
copyAlterSqlFailed: "Kopyalama başarısız: {message}",
copySqlCondition: "SQL Koşulunu Kopyala",
copyQuerySql: "SQL Kopyala",
copyExecutableSql: "SQL'i kopyala (yorumlar olmadan)",
transpose: "Satırı Devrik Göster",
transposeMultiRowToggle: "Çok satırlı",
transposeSingleRow: "Geçerli satır",
+2
View File
@@ -2332,6 +2332,8 @@ export default withEnglishFallback({
noAlterSqlAvailable: "该列无 ALTER SQL 可复制",
copyAlterSqlFailed: "复制失败:{message}",
copySqlCondition: "复制 SQL 条件",
copyQuerySql: "复制 SQL",
copyExecutableSql: "复制 SQL(不含注释)",
transpose: "转置查看",
transposeMultiRowToggle: "多行转置",
transposeSingleRow: "当前行",
+2
View File
@@ -2252,6 +2252,8 @@ export default withEnglishFallback({
noAlterSqlAvailable: "此欄沒有可複製的 ALTER SQL",
copyAlterSqlFailed: "複製失敗:{message}",
copySqlCondition: "複製 SQL 條件",
copyQuerySql: "複製 SQL",
copyExecutableSql: "複製 SQL(不含註解)",
transpose: "轉置檢視",
transposeMultiRowToggle: "多列轉置",
transposeSingleRow: "目前列",
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { sqlWithoutCommentsForCopy } from "@/lib/sql/sqlWithoutCommentsForCopy";
describe("sqlWithoutCommentsForCopy", () => {
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);
});
});
@@ -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;
}