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:
parent
29495a7220
commit
705bacba04
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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を展開中...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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: "新建查询",
|
||||
|
|
|
|||
|
|
@ -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……",
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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')");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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, "''")}'`;
|
||||
}
|
||||
Loading…
Reference in New Issue