feat(grid): copy query SQL without ordinary comments
This commit is contained in:
13 files changed
+151
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2256,6 +2256,8 @@ export default withEnglishFallback({
|
||||
noAlterSqlAvailable: "この列に利用可能なALTER SQLはありません",
|
||||
copyAlterSqlFailed: "コピーに失敗しました: {message}",
|
||||
copySqlCondition: "SQL条件をコピー",
|
||||
copyQuerySql: "SQLをコピー",
|
||||
copyExecutableSql: "SQLをコピー(コメントなし)",
|
||||
transpose: "行を転置",
|
||||
transposeMultiRowToggle: "複数行",
|
||||
transposeSingleRow: "現在の行",
|
||||
|
||||
@@ -2238,6 +2238,8 @@ export default withEnglishFallback({
|
||||
noAlterSqlAvailable: "이 컬럼에 사용 가능한 ALTER SQL이 없습니다",
|
||||
copyAlterSqlFailed: "복사 실패: {message}",
|
||||
copySqlCondition: "SQL 조건 복사",
|
||||
copyQuerySql: "SQL 복사",
|
||||
copyExecutableSql: "SQL 복사 (주석 제외)",
|
||||
transpose: "행 전치",
|
||||
transposeMultiRowToggle: "다중 행",
|
||||
transposeSingleRow: "현재 행",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2332,6 +2332,8 @@ export default withEnglishFallback({
|
||||
noAlterSqlAvailable: "该列无 ALTER SQL 可复制",
|
||||
copyAlterSqlFailed: "复制失败:{message}",
|
||||
copySqlCondition: "复制 SQL 条件",
|
||||
copyQuerySql: "复制 SQL",
|
||||
copyExecutableSql: "复制 SQL(不含注释)",
|
||||
transpose: "转置查看",
|
||||
transposeMultiRowToggle: "多行转置",
|
||||
transposeSingleRow: "当前行",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user