feat(editor): add ExPaste paste-as-IN-condition helper (#2746)

* feat: add ExPaste SQL IN condition helper

* fix: restrict ExPaste to SQL-like editors

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipg 2026-07-07 12:33:49 +08:00 committed by GitHub
parent 29495a7220
commit 705bacba04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 491 additions and 8 deletions

View File

@ -878,6 +878,10 @@ async function importResultArchive() {
}
}
function pasteClipboardAsSqlInCondition() {
void contentAreaRef.value?.pasteClipboardAsSqlInCondition?.();
}
async function openSqlFilePath(path: string) {
if (!isTauriRuntime()) return;
try {
@ -1819,6 +1823,7 @@ onUnmounted(() => {
@save-sql="void openSaveSqlDialog()"
@open-sql="openSqlFile"
@import-result-archive="importResultArchive"
@paste-sql-in-condition="pasteClipboardAsSqlInCondition"
@change-connection="changeActiveConnection"
@change-database="changeActiveDatabase"
@change-schema="changeActiveSchema"

View File

@ -8,12 +8,13 @@ import { search as cmSearch } from "@codemirror/search";
import EditorSearchPanel from "./EditorSearchPanel.vue";
import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { copyToClipboard } from "@/lib/common/clipboard";
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
import { currentStatementFrameRangeTo, visualSqlColumns } from "@/lib/sql/currentStatementFrame";
import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
import { formatMongoShellText } from "@/lib/mongo/mongoFormatter";
import { useConnectionStore, COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
@ -54,7 +55,7 @@ import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
import { qualifiedTableNameAtSqlPosition } from "@/lib/sql/queryCursorTableTarget";
import * as api from "@/lib/backend/api";
@ -738,6 +739,50 @@ function convertSelectedSqlCase(mode: SelectionCaseMode): boolean {
return false;
}
async function pasteClipboardAsSqlInCondition(): Promise<boolean> {
if (!supportsSqlInListPaste(props.databaseType)) return false;
if (props.readOnly) return false;
const currentView = view.value;
if (!currentView) return false;
const selection = currentView.state.selection.main;
const selectedSource = selection.empty ? "" : currentView.state.sliceDoc(selection.from, selection.to);
let source = selectedSource;
if (!source) {
try {
source = await readTextFromClipboard();
} catch (e: any) {
toast(t("editor.exPasteClipboardReadFailed", { message: e?.message || String(e) }), 5000);
focusEditor();
return false;
}
}
const result = buildSqlInConditionFromPasteSource(source);
if (!result.ok) {
const key = result.reason === "too-large" ? "editor.exPasteTooLarge" : result.reason === "too-many-values" ? "editor.exPasteTooManyValues" : result.reason === "not-list" ? "editor.exPasteNotList" : "editor.exPasteNoValues";
toast(t(key, { limit: result.limit ?? 0 }), 5000);
focusEditor();
return false;
}
if (view.value !== currentView || props.readOnly) return false;
const state = currentView.state;
const line = state.doc.lineAt(selection.from);
const prefix = state.sliceDoc(line.from, selection.from);
const insertText = insertTextForSqlInCondition(result.sql, prefix);
currentView.dispatch({
changes: { from: selection.from, to: selection.to, insert: insertText },
selection: { anchor: selection.from + insertText.length },
scrollIntoView: true,
userEvent: "input.paste",
});
currentView.focus();
toast(t("editor.exPastePasted", { count: result.valueCount }), 2000);
return true;
}
function openTableFromContextMenu() {
if (!contextTableName.value) return;
emit("viewTableData", contextTableName.value);
@ -887,6 +932,11 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
...binding(shortcuts.selectAll, (view) => codeMirrorSelectAll?.(view) ?? false),
...binding(shortcuts.uppercaseSelection, () => convertSelectedSqlCase("upper")),
...binding(shortcuts.lowercaseSelection, () => convertSelectedSqlCase("lower")),
...binding(shortcuts.exPasteSqlInCondition, () => {
if (!supportsSqlInListPaste(props.databaseType)) return false;
void pasteClipboardAsSqlInCondition();
return true;
}),
]),
) ?? [],
codeMirrorKeymap.of(
@ -3254,7 +3304,7 @@ function scrollCursorIntoView() {
});
}
defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute });
defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pasteClipboardAsSqlInCondition });
</script>
<template>

View File

@ -727,7 +727,11 @@ function requestQueryEditorExecute() {
return queryEditorRef.value?.requestExecute();
}
defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExecute });
function pasteClipboardAsSqlInCondition() {
return queryEditorRef.value?.pasteClipboardAsSqlInCondition();
}
defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExecute, pasteClipboardAsSqlInCondition });
</script>
<template>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { Play, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, Layers, X, Shield, Upload, RotateCcw, AlertTriangle } from "@lucide/vue";
import { Play, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, Layers, X, Shield, Upload, RotateCcw, AlertTriangle, ClipboardPaste } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
@ -13,7 +13,7 @@ import { useSchemaOptions } from "@/composables/useSchemaOptions";
import { connectionIconType } from "@/lib/connection/connectionPresentation";
import { formatDatabaseLabel, isDefaultDatabase } from "@/lib/database/defaultDatabase";
import { connectionDisplayName } from "@/lib/tabs/tabPresentation";
import { isSingleDatabase, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
import { isSingleDatabase, supportsSqlInListPaste, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
import { hexToRgba } from "@/lib/common/color";
import type { QueryTab, ConnectionConfig } from "@/types/database";
@ -40,6 +40,7 @@ const emit = defineEmits<{
saveSql: [];
openSql: [];
importResultArchive: [];
pasteSqlInCondition: [];
changeConnection: [connectionId: string];
changeDatabase: [database: string];
changeSchema: [schema: string | undefined];
@ -71,6 +72,7 @@ const supportsExplain = computed(() => {
return dbType !== "redis" && dbType !== "mongodb" && dbType !== "elasticsearch" && dbType !== "qdrant" && dbType !== "milvus" && dbType !== "weaviate" && dbType !== "chromadb" && dbType !== "etcd" && dbType !== "zookeeper" && dbType !== "mq" && dbType !== "nacos";
});
const isSingleDb = computed(() => isSingleDatabase(props.activeConnection?.db_type));
const supportsExPaste = computed(() => supportsSqlInListPaste(props.activeConnection?.db_type));
const supportsTransaction = computed(() => supportsTransactionFeature(props.activeConnection?.db_type));
const hasDefaultDatabaseOption = computed(() => activeDatabaseOptions.value.includes(""));
const schemaDatabaseKey = computed(() => props.activeTab.database || (isSingleDb.value ? "_" : ""));
@ -288,6 +290,14 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
</TooltipTrigger>
<TooltipContent>{{ t("tabs.importResultArchive") }}</TooltipContent>
</Tooltip>
<Tooltip v-if="supportsExPaste">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-teal-600 hover:bg-teal-500/10 hover:text-teal-700 dark:text-teal-300 dark:hover:text-teal-200" @click="emit('pasteSqlInCondition')">
<ClipboardPaste class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.exPasteSqlInCondition") }}</TooltipContent>
</Tooltip>
</div>
<span class="flex-1 min-w-0" />
<div class="flex items-center gap-2 shrink-0">

View File

@ -45,6 +45,7 @@ export default {
hidePreviewSql: "Hide SQL Preview",
saveSql: "Save to SQL Library",
openSql: "Open SQL file",
exPasteSqlInCondition: "ExPaste: paste as IN condition",
theme: "Theme",
themeLight: "Light",
themeDark: "Dark",
@ -458,6 +459,12 @@ export default {
setDefaultDatabase: "Set Default",
defaultDatabase: "Default",
clearDatabase: "Clear database",
exPasteNoValues: "Clipboard has no values for an IN condition",
exPasteNotList: "No convertible multi-value list was detected",
exPasteTooLarge: "Clipboard content is too large. Maximum supported length is {limit} characters.",
exPasteTooManyValues: "Too many values. Maximum supported count is {limit}.",
exPasteClipboardReadFailed: "Failed to read clipboard: {message}",
exPastePasted: "Pasted {count} IN condition values",
completion: {
nullValue: "NULL value",
isNull: "Check if NULL",
@ -3166,6 +3173,7 @@ export default {
officialDocs: "Official docs",
shortcutUppercaseSelection: "Convert selection to uppercase",
shortcutLowercaseSelection: "Convert selection to lowercase",
shortcutExPasteSqlInCondition: "ExPaste: paste as IN condition",
},
driverStore: {
progressJreExtract: "Extracting JRE...",

View File

@ -47,6 +47,7 @@ export default withEnglishFallback({
hidePreviewSql: "Ocultar vista previa de SQL",
saveSql: "Guardar en biblioteca SQL",
openSql: "Abrir archivo SQL",
exPasteSqlInCondition: "ExPaste: pegar como condición IN",
theme: "Tema",
themeLight: "Claro",
themeDark: "Oscuro",
@ -451,6 +452,12 @@ export default withEnglishFallback({
setDefaultDatabase: "Establecer como predeterminada",
defaultDatabase: "Predeterminada",
clearDatabase: "Limpiar base de datos",
exPasteNoValues: "El portapapeles no contiene valores para una condición IN",
exPasteNotList: "No se detectó una lista de varios valores convertible",
exPasteTooLarge: "El contenido del portapapeles es demasiado grande. Máximo {limit} caracteres.",
exPasteTooManyValues: "Demasiados valores. Máximo {limit}.",
exPasteClipboardReadFailed: "No se pudo leer el portapapeles: {message}",
exPastePasted: "Se pegaron {count} valores de condición IN",
completion: {
nullValue: "Valor NULL",
isNull: "Comprobar si es NULL",
@ -3079,6 +3086,7 @@ export default withEnglishFallback({
officialDocs: "Documentación oficial",
shortcutUppercaseSelection: "Convertir selección a mayúsculas",
shortcutLowercaseSelection: "Convertir selección a minúsculas",
shortcutExPasteSqlInCondition: "ExPaste: pegar como condición IN",
},
driverStore: {
progressJreExtract: "Extrayendo JRE...",

View File

@ -46,6 +46,7 @@ export default withEnglishFallback({
hidePreviewSql: "Nascondi anteprima SQL",
saveSql: "Salva nella Libreria SQL",
openSql: "Apri file SQL",
exPasteSqlInCondition: "ExPaste: incolla come condizione IN",
theme: "Tema",
themeLight: "Chiaro",
themeDark: "Scuro",
@ -449,6 +450,12 @@ export default withEnglishFallback({
setDefaultDatabase: "Imposta Predefinito",
defaultDatabase: "Predefinito",
clearDatabase: "Cancella database",
exPasteNoValues: "Gli appunti non contengono valori per una condizione IN",
exPasteNotList: "Nessun elenco di più valori convertibile rilevato",
exPasteTooLarge: "Il contenuto degli appunti è troppo grande. Massimo {limit} caratteri.",
exPasteTooManyValues: "Troppi valori. Massimo {limit}.",
exPasteClipboardReadFailed: "Impossibile leggere gli appunti: {message}",
exPastePasted: "Incollati {count} valori per la condizione IN",
completion: {
nullValue: "Valore NULL",
isNull: "Verifica se NULL",
@ -3077,6 +3084,7 @@ export default withEnglishFallback({
officialDocs: "Documenti ufficiali",
shortcutUppercaseSelection: "Converti selezione in maiuscolo",
shortcutLowercaseSelection: "Converti selezione in minuscolo",
shortcutExPasteSqlInCondition: "ExPaste: incolla come condizione IN",
},
driverStore: {
progressJreExtract: "Estrazione JRE...",

View File

@ -47,6 +47,7 @@ export default withEnglishFallback({
hidePreviewSql: "SQLプレビューを非表示",
saveSql: "SQLライブラリに保存",
openSql: "SQLファイルを開く",
exPasteSqlInCondition: "ExPaste: IN条件として貼り付け",
theme: "テーマ",
themeLight: "ライト",
themeDark: "ダーク",
@ -448,6 +449,12 @@ export default withEnglishFallback({
setDefaultDatabase: "デフォルトに設定",
defaultDatabase: "デフォルト",
clearDatabase: "データベースをクリア",
exPasteNoValues: "クリップボードにIN条件で使える値がありません",
exPasteNotList: "変換可能な複数値リストを検出できませんでした",
exPasteTooLarge: "クリップボードの内容が大きすぎます。最大 {limit} 文字まで対応しています。",
exPasteTooManyValues: "値が多すぎます。最大 {limit} 件まで対応しています。",
exPasteClipboardReadFailed: "クリップボードの読み取りに失敗しました: {message}",
exPastePasted: "{count} 件のIN条件値を貼り付けました",
completion: {
nullValue: "NULL値",
isNull: "NULLかどうか",
@ -3077,6 +3084,7 @@ export default withEnglishFallback({
officialDocs: "公式ドキュメント",
shortcutUppercaseSelection: "選択範囲を大文字に変換",
shortcutLowercaseSelection: "選択範囲を小文字に変換",
shortcutExPasteSqlInCondition: "ExPaste: IN条件として貼り付け",
},
driverStore: {
progressJreExtract: "JREを展開中...",

View File

@ -47,6 +47,7 @@ export default withEnglishFallback({
hidePreviewSql: "Ocultar Visualização SQL",
saveSql: "Salvar na Biblioteca SQL",
openSql: "Abrir arquivo SQL",
exPasteSqlInCondition: "ExPaste: colar como condição IN",
theme: "Tema",
themeLight: "Claro",
themeDark: "Escuro",
@ -450,6 +451,12 @@ export default withEnglishFallback({
setDefaultDatabase: "Definir Padrão",
defaultDatabase: "Padrão",
clearDatabase: "Limpar banco de dados",
exPasteNoValues: "A área de transferência não contém valores para uma condição IN",
exPasteNotList: "Nenhuma lista de múltiplos valores conversível foi detectada",
exPasteTooLarge: "O conteúdo da área de transferência é grande demais. Máximo de {limit} caracteres.",
exPasteTooManyValues: "Valores demais. Máximo de {limit}.",
exPasteClipboardReadFailed: "Falha ao ler a área de transferência: {message}",
exPastePasted: "{count} valores de condição IN colados",
completion: {
nullValue: "Valor NULL",
isNull: "Verificar se é NULL",
@ -3078,6 +3085,7 @@ export default withEnglishFallback({
officialDocs: "Documentação oficial",
shortcutUppercaseSelection: "Converter seleção em maiúsculas",
shortcutLowercaseSelection: "Converter seleção em minúsculas",
shortcutExPasteSqlInCondition: "ExPaste: colar como condição IN",
},
driverStore: {
progressJreExtract: "Extraindo JRE...",

View File

@ -47,6 +47,7 @@ export default withEnglishFallback({
hidePreviewSql: "隐藏 SQL 预览",
saveSql: "保存到 SQL 库",
openSql: "打开 SQL 文件",
exPasteSqlInCondition: "ExPaste粘贴为 IN 条件",
theme: "主题",
themeLight: "亮色",
themeDark: "暗色",
@ -460,6 +461,12 @@ export default withEnglishFallback({
setDefaultDatabase: "设为默认",
defaultDatabase: "默认库",
clearDatabase: "清除数据库选择",
exPasteNoValues: "剪贴板中没有可用于 IN 条件的值",
exPasteNotList: "未识别到可转换的多值列表",
exPasteTooLarge: "剪贴板内容过大,最多支持 {limit} 字符",
exPasteTooManyValues: "值过多,最多支持 {limit} 个",
exPasteClipboardReadFailed: "读取剪贴板失败:{message}",
exPastePasted: "已粘贴 {count} 个 IN 条件值",
completion: {
nullValue: "空值",
isNull: "判断是否为 NULL",
@ -3034,6 +3041,7 @@ export default withEnglishFallback({
shortcutSelectAll: "全选",
shortcutUppercaseSelection: "选中内容转为大写",
shortcutLowercaseSelection: "选中内容转为小写",
shortcutExPasteSqlInCondition: "ExPaste粘贴为 IN 条件",
shortcutCopyCurrentRow: "复制当前数据行",
shortcutDeleteCurrentRow: "删除当前数据行",
shortcutNewQuery: "新建查询",

View File

@ -47,6 +47,7 @@ export default withEnglishFallback({
hidePreviewSql: "隱藏 SQL 預覽",
saveSql: "儲存到 SQL 庫",
openSql: "開啟 SQL 檔案",
exPasteSqlInCondition: "ExPaste貼上為 IN 條件",
theme: "主題",
themeLight: "亮色",
themeDark: "暗色",
@ -450,6 +451,12 @@ export default withEnglishFallback({
setDefaultDatabase: "設為預設",
defaultDatabase: "預設資料庫",
clearDatabase: "清除資料庫",
exPasteNoValues: "剪貼簿中沒有可用於 IN 條件的值",
exPasteNotList: "未識別到可轉換的多值列表",
exPasteTooLarge: "剪貼簿內容過大,最多支援 {limit} 個字元",
exPasteTooManyValues: "值過多,最多支援 {limit} 個",
exPasteClipboardReadFailed: "讀取剪貼簿失敗:{message}",
exPastePasted: "已貼上 {count} 個 IN 條件值",
completion: {
nullValue: "空值",
isNull: "判斷是否為 NULL",
@ -2981,6 +2988,7 @@ export default withEnglishFallback({
queryExportKeysetOptimizationEnabledDescription: "僅適用於可安全識別的單表查詢;複雜查詢會自動回退。",
shortcutUppercaseSelection: "選取內容轉為大寫",
shortcutLowercaseSelection: "選取內容轉為小寫",
shortcutExPasteSqlInCondition: "ExPaste貼上為 IN 條件",
},
driverStore: {
progressJreExtract: "解壓縮 JRE……",

View File

@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { connectionNamespaceCreationTarget, databaseNodeNamespaceCreationTarget } from "@/lib/database/databaseNamespaceCreation";
import { editableDatabasePropertyGroups, editableSchemaPropertyGroups } from "@/lib/database/databasePropertyEditing";
import { buildGetDatabaseCommentSql } from "@/lib/database/dbAdminSql";
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
import { supportsSqlInListPaste, supportsTransaction } from "@/lib/database/databaseFeatureSupport";
describe("supportsTransaction", () => {
it("returns true for supported database types", () => {
@ -30,6 +30,39 @@ describe("supportsTransaction", () => {
});
});
describe("supportsSqlInListPaste", () => {
it("allows generic and SQL-like editors", () => {
expect(supportsSqlInListPaste(undefined)).toBe(true);
expect(supportsSqlInListPaste("mysql")).toBe(true);
expect(supportsSqlInListPaste("postgres")).toBe(true);
expect(supportsSqlInListPaste("oracle")).toBe(true);
expect(supportsSqlInListPaste("sqlserver")).toBe(true);
expect(supportsSqlInListPaste("sqlite")).toBe(true);
expect(supportsSqlInListPaste("cassandra")).toBe(true);
expect(supportsSqlInListPaste("tdengine")).toBe(true);
expect(supportsSqlInListPaste("iotdb")).toBe(true);
expect(supportsSqlInListPaste("jdbc")).toBe(true);
});
it("hides SQL IN list paste in non-SQL editors", () => {
expect(supportsSqlInListPaste("redis")).toBe(false);
expect(supportsSqlInListPaste("mongodb")).toBe(false);
expect(supportsSqlInListPaste("elasticsearch")).toBe(false);
expect(supportsSqlInListPaste("qdrant")).toBe(false);
expect(supportsSqlInListPaste("milvus")).toBe(false);
expect(supportsSqlInListPaste("weaviate")).toBe(false);
expect(supportsSqlInListPaste("chromadb")).toBe(false);
expect(supportsSqlInListPaste("etcd")).toBe(false);
expect(supportsSqlInListPaste("zookeeper")).toBe(false);
expect(supportsSqlInListPaste("mq")).toBe(false);
expect(supportsSqlInListPaste("nacos")).toBe(false);
});
it("excludes Neo4j because Cypher uses list syntax instead of SQL IN tuples", () => {
expect(supportsSqlInListPaste("neo4j")).toBe(false);
});
});
describe("database property editing", () => {
it("allows MySQL-compatible charset and collation edits on database nodes", () => {
expect(editableDatabasePropertyGroups({ db_type: "mysql" }, { type: "database", database: "app" })).toEqual(["charsetCollation"]);

View File

@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { DEFAULT_SHORTCUT_SETTINGS, SHORTCUT_DEFINITIONS, findShortcutConflict, formatShortcut, normalizeShortcutSettings, shortcutToCodeMirrorKey, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
describe("shortcutRegistry editor actions", () => {
const formatterEditorActionIds: ShortcutActionId[] = ["formatSql", "indentMore", "indentLess", "duplicateLine", "deleteLine", "moveLineUp", "moveLineDown", "copyLineUp", "copyLineDown", "undo", "redo", "selectAll", "uppercaseSelection", "lowercaseSelection"];
const formatterEditorActionIds: ShortcutActionId[] = ["formatSql", "indentMore", "indentLess", "duplicateLine", "deleteLine", "moveLineUp", "moveLineDown", "copyLineUp", "copyLineDown", "undo", "redo", "selectAll", "uppercaseSelection", "lowercaseSelection", "exPasteSqlInCondition"];
const sidebarShortcutActionIds: ShortcutActionId[] = ["copySidebarSelection", "pasteSidebarSelection", "editSidebarConnection"];
it("registers formatter editor shortcuts in the generic editor scope", () => {
@ -32,6 +32,7 @@ describe("shortcutRegistry editor actions", () => {
expect(shortcuts.selectAll).toBe("Mod+A");
expect(shortcuts.uppercaseSelection).toBe("Shift+Alt+U");
expect(shortcuts.lowercaseSelection).toBe("Shift+Alt+L");
expect(shortcuts.exPasteSqlInCondition).toBe("");
});
it("detects conflicts between formatter editor shortcuts and other editor shortcuts", () => {

View File

@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition, SQL_IN_LIST_PASTE_MAX_SOURCE_LENGTH, SQL_IN_LIST_PASTE_MAX_VALUES } from "@/lib/sql/sqlInListPaste";
describe("sqlInListPaste", () => {
it("builds an IN condition from newline values", () => {
expect(buildSqlInConditionFromPasteSource("A\nB\nC")).toEqual({
ok: true,
sql: "IN ('A', 'B', 'C')",
valueCount: 3,
});
});
it("splits comma, tab, and newline separated clipboard content", () => {
expect(buildSqlInConditionFromPasteSource("A\tB\nC,D")).toEqual({
ok: true,
sql: "IN ('A', 'B', 'C', 'D')",
valueCount: 4,
});
});
it("splits simple slash-separated value lists", () => {
expect(buildSqlInConditionFromPasteSource("1/2/3")).toEqual({
ok: true,
sql: "IN (1, 2, 3)",
valueCount: 3,
});
expect(buildSqlInConditionFromPasteSource("A/B/C")).toEqual({
ok: true,
sql: "IN ('A', 'B', 'C')",
valueCount: 3,
});
});
it("does not treat common dates, URLs, or absolute paths as value lists", () => {
expect(buildSqlInConditionFromPasteSource("2026/07/07")).toEqual({ ok: false, reason: "not-list" });
expect(buildSqlInConditionFromPasteSource("http://example.com/a/b")).toEqual({ ok: false, reason: "not-list" });
expect(buildSqlInConditionFromPasteSource("/Users/staff/dbx")).toEqual({ ok: false, reason: "not-list" });
});
it("preserves numeric and NULL literals while quoting strings", () => {
expect(buildSqlInConditionFromPasteSource("1\n-2.5\n001\nnull\nA1")).toEqual({
ok: true,
sql: "IN (1, -2.5, '001', NULL, 'A1')",
valueCount: 5,
});
});
it("strips existing SQL list wrappers and escapes single quotes once", () => {
expect(buildSqlInConditionFromPasteSource("('O''Reilly', \"Bob\")")).toEqual({
ok: true,
sql: "IN ('O''Reilly', 'Bob')",
valueCount: 2,
});
expect(buildSqlInConditionFromPasteSource("IN ('A', 'B')")).toEqual({
ok: true,
sql: "IN ('A', 'B')",
valueCount: 2,
});
expect(buildSqlInConditionFromPasteSource("('A')")).toEqual({
ok: true,
sql: "IN ('A')",
valueCount: 1,
});
});
it("does not split delimiters inside quoted values", () => {
expect(buildSqlInConditionFromPasteSource("'A,B'\n'C\tD'")).toEqual({
ok: true,
sql: "IN ('A,B', 'C\tD')",
valueCount: 2,
});
});
it("treats apostrophes inside unquoted values as text", () => {
expect(buildSqlInConditionFromPasteSource("O'Reilly\nBob")).toEqual({
ok: true,
sql: "IN ('O''Reilly', 'Bob')",
valueCount: 2,
});
});
it("returns empty for blank or delimiter-only content", () => {
expect(buildSqlInConditionFromPasteSource(" \n,\t ")).toEqual({ ok: false, reason: "empty" });
});
it("does not process unrelated single text values", () => {
expect(buildSqlInConditionFromPasteSource("paste some random text here")).toEqual({ ok: false, reason: "not-list" });
expect(buildSqlInConditionFromPasteSource("O'Reilly")).toEqual({ ok: false, reason: "not-list" });
});
it("guards very large paste input and value count", () => {
expect(buildSqlInConditionFromPasteSource("x".repeat(SQL_IN_LIST_PASTE_MAX_SOURCE_LENGTH + 1))).toEqual({
ok: false,
reason: "too-large",
limit: SQL_IN_LIST_PASTE_MAX_SOURCE_LENGTH,
});
expect(buildSqlInConditionFromPasteSource(Array.from({ length: SQL_IN_LIST_PASTE_MAX_VALUES + 1 }, (_, index) => String(index)).join("\n"))).toEqual({
ok: false,
reason: "too-many-values",
limit: SQL_IN_LIST_PASTE_MAX_VALUES,
});
});
it("avoids duplicating IN when the cursor is already after IN or NOT IN", () => {
expect(insertTextForSqlInCondition("IN ('A')", "where id in ")).toBe("('A')");
expect(insertTextForSqlInCondition("IN ('A')", "where id not in")).toBe("('A')");
expect(insertTextForSqlInCondition("IN ('A')", "where id")).toBe(" IN ('A')");
expect(insertTextForSqlInCondition("IN ('A')", "where id = ")).toBe("IN ('A')");
});
});

View File

@ -38,6 +38,13 @@ export function supportsSqlFileExecution(dbType?: DatabaseType): boolean {
return supportsDatabaseFeature(dbType, "sqlFileExecution");
}
const NON_SQL_IN_LIST_PASTE_TYPES = new Set<DatabaseType>(["neo4j"]);
export function supportsSqlInListPaste(dbType?: DatabaseType): boolean {
if (!dbType) return true;
return supportsSqlFileExecution(dbType) && !NON_SQL_IN_LIST_PASTE_TYPES.has(dbType);
}
export function supportsSchemaDiagram(dbType?: DatabaseType): boolean {
return supportsDatabaseFeature(dbType, "diagram");
}

View File

@ -18,6 +18,7 @@ export type ShortcutActionId =
| "selectAll"
| "uppercaseSelection"
| "lowercaseSelection"
| "exPasteSqlInCondition"
| "copyCurrentRow"
| "deleteCurrentRow"
| "newQuery"
@ -163,6 +164,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: "editor",
defaultShortcut: "Shift+Alt+L",
},
{
id: "exPasteSqlInCondition",
labelKey: "settings.shortcutExPasteSqlInCondition",
scope: "editor",
defaultShortcut: "",
},
{
id: "copyCurrentRow",
labelKey: "settings.shortcutCopyCurrentRow",

View File

@ -0,0 +1,200 @@
export const SQL_IN_LIST_PASTE_MAX_SOURCE_LENGTH = 1024 * 1024;
export const SQL_IN_LIST_PASTE_MAX_VALUES = 10_000;
export type SqlInListPasteError = "empty" | "not-list" | "too-large" | "too-many-values";
export type SqlInListPasteResult =
| {
ok: true;
sql: string;
valueCount: number;
}
| {
ok: false;
reason: SqlInListPasteError;
limit?: number;
};
interface ParsedPasteValue {
value: string;
quoted: boolean;
}
interface ParsedPasteValues {
values: ParsedPasteValue[];
explicitList: boolean;
}
const SQL_NUMBER_LITERAL_RE = /^[+-]?(?:(?:0|[1-9]\d*)(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
const SIMPLE_SLASH_LIST_VALUE_RE = /^[A-Za-z0-9_.:-]+$/;
export function buildSqlInConditionFromPasteSource(source: string): SqlInListPasteResult {
if (source.length > SQL_IN_LIST_PASTE_MAX_SOURCE_LENGTH) {
return { ok: false, reason: "too-large", limit: SQL_IN_LIST_PASTE_MAX_SOURCE_LENGTH };
}
const parsed = parsePastedValues(source);
const values = parsed.values;
if (values.length === 0) return { ok: false, reason: "empty" };
if (values.length === 1 && !parsed.explicitList) return { ok: false, reason: "not-list" };
if (values.length > SQL_IN_LIST_PASTE_MAX_VALUES) {
return { ok: false, reason: "too-many-values", limit: SQL_IN_LIST_PASTE_MAX_VALUES };
}
const literals = values.map(formatSqlLiteral);
return {
ok: true,
sql: `IN (${literals.join(", ")})`,
valueCount: values.length,
};
}
export function sqlInConditionNeedsListOnly(prefix: string): boolean {
return /\b(?:not\s+)?in\s*$/i.test(prefix);
}
export function insertTextForSqlInCondition(condition: string, prefix: string): string {
if (sqlInConditionNeedsListOnly(prefix)) return condition.replace(/^IN\s+/i, "");
if (!prefix || /\s$|\($/.test(prefix)) return condition;
return ` ${condition}`;
}
function parsePastedValues(source: string): ParsedPasteValues {
const sourceText = source.replace(/\r\n?/g, "\n").trim();
const withoutInKeyword = stripLeadingInKeyword(sourceText);
const explicitSource = withoutInKeyword.trim();
const explicitList = explicitSource.startsWith("(") && explicitSource.endsWith(")") && hasSingleWrappingParentheses(explicitSource);
const trimmed = stripOuterParentheses(withoutInKeyword);
if (!trimmed) return { values: [], explicitList };
return {
values: splitPasteTokens(trimmed)
.map(normalizePastedToken)
.filter((value): value is ParsedPasteValue => !!value),
explicitList,
};
}
function stripLeadingInKeyword(value: string): string {
const match = /^(?:not\s+)?in\b\s*([\s\S]*)$/i.exec(value.trim());
return match ? match[1].trim() : value;
}
function splitPasteTokens(source: string): string[] {
const tokens: string[] = [];
let current = "";
let quote: "'" | '"' | null = null;
const slashIsDelimiter = shouldSplitSlashSeparatedValues(source);
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (quote) {
current += char;
if (char === quote) {
if (next === quote) {
current += next;
index += 1;
} else {
quote = null;
}
}
continue;
}
if ((char === "'" || char === '"') && current.trim().length === 0) {
quote = char;
current += char;
continue;
}
if (char === "," || char === "\n" || char === "\t" || (slashIsDelimiter && char === "/")) {
tokens.push(current);
current = "";
continue;
}
current += char;
}
tokens.push(current);
return tokens;
}
function shouldSplitSlashSeparatedValues(source: string): boolean {
const trimmed = source.trim();
if (!trimmed.includes("/") || /[,\n\t]/.test(trimmed)) return false;
if (trimmed.includes("'") || trimmed.includes('"')) return false;
if (trimmed.startsWith("/") || /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return false;
if (/^\d{4}\/\d{1,2}\/\d{1,2}(?:\D|$)/.test(trimmed) || /^\d{1,2}\/\d{1,2}\/\d{4}(?:\D|$)/.test(trimmed)) return false;
const parts = trimmed.split("/").map((part) => part.trim());
if (parts.length < 2 || parts.some((part) => !part)) return false;
return parts.every((part) => SIMPLE_SLASH_LIST_VALUE_RE.test(part));
}
function normalizePastedToken(token: string): ParsedPasteValue | null {
let value = stripOuterParentheses(
token
.trim()
.replace(/[;,]+$/g, "")
.trim(),
);
if (!value) return null;
const first = value[0];
const last = value[value.length - 1];
const quoted = value.length >= 2 && ((first === "'" && last === "'") || (first === '"' && last === '"'));
if (quoted) {
value = value.slice(1, -1);
value = first === "'" ? value.replace(/''/g, "'") : value.replace(/""/g, '"');
}
value = value.trim();
if (!value) return null;
return { value, quoted };
}
function stripOuterParentheses(value: string): string {
let next = value.trim();
while (next.startsWith("(") && next.endsWith(")") && hasSingleWrappingParentheses(next)) {
next = next.slice(1, -1).trim();
}
return next;
}
function hasSingleWrappingParentheses(value: string): boolean {
let depth = 0;
let quote: "'" | '"' | null = null;
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
const next = value[index + 1];
if (quote) {
if (char === quote) {
if (next === quote) index += 1;
else quote = null;
}
continue;
}
if (char === "'" || char === '"') {
quote = char;
continue;
}
if (char === "(") depth += 1;
if (char === ")") depth -= 1;
if (depth === 0 && index < value.length - 1) return false;
}
return depth === 0;
}
function formatSqlLiteral(token: ParsedPasteValue): string {
if (!token.quoted && /^null$/i.test(token.value)) return "NULL";
if (!token.quoted && SQL_NUMBER_LITERAL_RE.test(token.value)) return token.value;
return `'${token.value.replace(/'/g, "''")}'`;
}