feat(editor): extend insert column hints to select

This commit is contained in:
Mo-Moore authored and GitHub committed 2026-09-01 13:11:58 +08:00
1 parent 2e8ed4b485
commit 600af87532
14 files changed
+341 -25

No files matched your search

@@ -129,7 +129,7 @@ import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer"; import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer"; import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer";
import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches"; import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches";
import { createInsertValueHintsExtension, requestInsertValueHintsRefresh } from "@/lib/editor/codemirrorInsertValueHints"; import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding"; import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
import { focusEditorView } from "@/lib/editor/queryEditorFocus"; import { focusEditorView } from "@/lib/editor/queryEditorFocus";
import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect";
@@ -727,8 +727,6 @@ const queryEditorAppearanceSettings = computed(() => {
wordWrap: settings.wordWrap, wordWrap: settings.wordWrap,
vimModeEnabled: settings.vimModeEnabled, vimModeEnabled: settings.vimModeEnabled,
autoCloseBrackets: settings.autoCloseBrackets, autoCloseBrackets: settings.autoCloseBrackets,
showCurrentStatementFrame: settings.showCurrentStatementFrame,
showInsertValueHints: settings.showInsertValueHints,
showLineNumbers: settings.showLineNumbers, showLineNumbers: settings.showLineNumbers,
shortcuts: settings.shortcuts, shortcuts: settings.shortcuts,
showStatementRunButtons: settings.showStatementRunButtons, showStatementRunButtons: settings.showStatementRunButtons,
@@ -2432,7 +2430,7 @@ function getInsertValueHintTableColumns(table: string, schema?: string, database
function requestInsertValueHintTableColumns(table: string, schema?: string, database?: string) { function requestInsertValueHintTableColumns(table: string, schema?: string, database?: string) {
if (!props.connectionId || props.database == null) return; if (!props.connectionId || props.database == null) return;
if (props.databaseType === "redis" || props.databaseType === "mongodb" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "victoriametrics") return; if (!supportsInsertValueHints(props.databaseType)) return;
const cacheKey = insertHintCacheKey({ name: table, schema, database }); const cacheKey = insertHintCacheKey({ name: table, schema, database });
const hasCachedColumns = props.databaseType === "sqlserver" ? cachedInsertValueHintColumnsByTable.has(cacheKey) : cachedColumnsByTable.has(cacheKey); const hasCachedColumns = props.databaseType === "sqlserver" ? cachedInsertValueHintColumnsByTable.has(cacheKey) : cachedColumnsByTable.has(cacheKey);
if (hasCachedColumns || pendingInsertValueHintColumnLoads.has(cacheKey)) return; if (hasCachedColumns || pendingInsertValueHintColumnLoads.has(cacheKey)) return;
@@ -5768,8 +5766,7 @@ onMounted(async () => {
sqlSignatureComp.of(buildSqlSignatureExtension()), sqlSignatureComp.of(buildSqlSignatureExtension()),
diagnosticComp.of(buildSqlDiagnosticExtension()), diagnosticComp.of(buildSqlDiagnosticExtension()),
createInsertValueHintsExtension({ createInsertValueHintsExtension({
isEnabled: () => isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "meilisearch" && props.databaseType !== "victoriametrics",
getTableColumns: getInsertValueHintTableColumns, getTableColumns: getInsertValueHintTableColumns,
requestTableColumns: requestInsertValueHintTableColumns, requestTableColumns: requestInsertValueHintTableColumns,
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }), getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
@@ -6670,7 +6667,7 @@ defineExpose({
shouldBlockExecutionShortcut, shouldBlockExecutionShortcut,
requestExecute, requestExecute,
requestExecuteInNewResultTab, requestExecuteInNewResultTab,
requestPreviewChanges, requestPreviewChanges,
captureExecutionSnapshot, captureExecutionSnapshot,
pasteClipboardAsSqlInCondition, pasteClipboardAsSqlInCondition,
focusStatementRange, focusStatementRange,
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, watchEffect } from "vue"; import { computed, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, Eye } from "@lucide/vue"; import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, BetweenVerticalStart, Eye } from "@lucide/vue";
import { supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
@@ -166,6 +167,12 @@ function toggleSqlSemanticDiagnostics() {
sqlSemanticDiagnosticsMode: sqlSemanticDiagnosticsEnabled.value ? "disabled" : "enabled", sqlSemanticDiagnosticsMode: sqlSemanticDiagnosticsEnabled.value ? "disabled" : "enabled",
}); });
} }
const insertValueHintsEnabled = computed(() => settingsStore.editorSettings.showInsertValueHints);
const insertValueHintsToggleTooltip = computed(() => (insertValueHintsEnabled.value ? t("toolbar.insertValueHintsToggleOn") : t("toolbar.insertValueHintsToggleOff")));
const supportsInsertValueHintsToggle = computed(() => supportsInsertValueHints(props.activeConnection?.db_type));
function toggleInsertValueHints() {
settingsStore.updateEditorSettings({ showInsertValueHints: !insertValueHintsEnabled.value });
}
const isTransactionActive = computed(() => !!props.txnSessionId); const isTransactionActive = computed(() => !!props.txnSessionId);
const isManualTransactionMode = computed(() => props.autoCommit === false || isTransactionActive.value); const isManualTransactionMode = computed(() => props.autoCommit === false || isTransactionActive.value);
const transactionModeBadge = computed(() => (isManualTransactionMode.value ? "M" : "A")); const transactionModeBadge = computed(() => (isManualTransactionMode.value ? "M" : "A"));
@@ -398,6 +405,22 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{{ sqlSemanticDiagnosticsToggleTooltip }}</TooltipContent> <TooltipContent>{{ sqlSemanticDiagnosticsToggleTooltip }}</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip v-if="supportsInsertValueHintsToggle">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
:class="insertValueHintsEnabled ? 'text-sky-600 bg-sky-500/10 hover:bg-sky-500/20 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200' : 'text-muted-foreground/50 hover:bg-muted hover:text-muted-foreground'"
:aria-label="insertValueHintsToggleTooltip"
:aria-pressed="insertValueHintsEnabled"
@click="toggleInsertValueHints"
>
<BetweenVerticalStart class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ insertValueHintsToggleTooltip }}</TooltipContent>
</Tooltip>
<Tooltip v-if="activeConnection?.db_type === 'redis'"> <Tooltip v-if="activeConnection?.db_type === 'redis'">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button <Button
+3 -1
View File
@@ -160,6 +160,8 @@ export default {
keywordCaseUpper: "Use upper-case SQL keywords", keywordCaseUpper: "Use upper-case SQL keywords",
sqlSemanticDiagnosticsToggleOn: "SQL semantic diagnostics: On (click to disable)", sqlSemanticDiagnosticsToggleOn: "SQL semantic diagnostics: On (click to disable)",
sqlSemanticDiagnosticsToggleOff: "SQL semantic diagnostics: Off (click to enable)", sqlSemanticDiagnosticsToggleOff: "SQL semantic diagnostics: Off (click to enable)",
insertValueHintsToggleOn: "INSERT column hints: On (click to disable)",
insertValueHintsToggleOff: "INSERT column hints: Off (click to enable)",
autoCommit: "Auto Commit", autoCommit: "Auto Commit",
manualTransaction: "Manual Transaction", manualTransaction: "Manual Transaction",
autoCommitAgent: "Auto Commit (depends on Agent backend)", autoCommitAgent: "Auto Commit (depends on Agent backend)",
@@ -6691,7 +6693,7 @@ export default {
showCurrentStatementFrame: "Show current statement frame", showCurrentStatementFrame: "Show current statement frame",
showCurrentStatementFrameDescription: "When enabled, the SQL editor draws an outline around the current executable statement; when disabled, the outline is hidden.", showCurrentStatementFrameDescription: "When enabled, the SQL editor draws an outline around the current executable statement; when disabled, the outline is hidden.",
showInsertValueHints: "Show INSERT value column hints", showInsertValueHints: "Show INSERT value column hints",
showInsertValueHintsDescription: "When enabled, the SQL editor shows column-name hints next to each value in INSERT ... VALUES clauses.", showInsertValueHintsDescription: "When enabled, the SQL editor shows column-name hints next to each value or SELECT projection in INSERT ... VALUES and INSERT ... SELECT clauses.",
previewStatementRunButton: "Preview statement run button", previewStatementRunButton: "Preview statement run button",
wordWrap: "Word wrap", wordWrap: "Word wrap",
wordWrapDescription: "Wrap long lines within the editor width", wordWrapDescription: "Wrap long lines within the editor width",
+3 -1
View File
@@ -162,6 +162,8 @@ export default withEnglishFallback({
keywordCaseUpper: "Usar palabras clave SQL en mayúsculas", keywordCaseUpper: "Usar palabras clave SQL en mayúsculas",
sqlSemanticDiagnosticsToggleOn: "Diagnóstico semántico de SQL: activado (clic para desactivar)", sqlSemanticDiagnosticsToggleOn: "Diagnóstico semántico de SQL: activado (clic para desactivar)",
sqlSemanticDiagnosticsToggleOff: "Diagnóstico semántico de SQL: desactivado (clic para activar)", sqlSemanticDiagnosticsToggleOff: "Diagnóstico semántico de SQL: desactivado (clic para activar)",
insertValueHintsToggleOn: "Sugerencias de columnas INSERT: activadas (clic para desactivar)",
insertValueHintsToggleOff: "Sugerencias de columnas INSERT: desactivadas (clic para activar)",
autoCommit: "Auto Commit", autoCommit: "Auto Commit",
manualTransaction: "Transacción Manual", manualTransaction: "Transacción Manual",
autoCommitAgent: "Auto Commit (depende del backend del Agente)", autoCommitAgent: "Auto Commit (depende del backend del Agente)",
@@ -6333,7 +6335,7 @@ export default withEnglishFallback({
showCurrentStatementFrame: "Mostrar marco de la sentencia actual", showCurrentStatementFrame: "Mostrar marco de la sentencia actual",
showCurrentStatementFrameDescription: "Al activarlo, el editor SQL dibuja un contorno alrededor de la sentencia ejecutable actual; al desactivarlo, se oculta.", showCurrentStatementFrameDescription: "Al activarlo, el editor SQL dibuja un contorno alrededor de la sentencia ejecutable actual; al desactivarlo, se oculta.",
showInsertValueHints: "Mostrar pistas de columnas en VALUES de INSERT", showInsertValueHints: "Mostrar pistas de columnas en VALUES de INSERT",
showInsertValueHintsDescription: "Al activarlo, el editor SQL muestra el nombre de columna junto a cada valor en cláusulas INSERT ... VALUES.", showInsertValueHintsDescription: "Al activarlo, el editor SQL muestra el nombre de columna junto a cada valor o proyección SELECT en cláusulas INSERT ... VALUES e INSERT ... SELECT.",
previewStatementRunButton: "Botón de ejecución de sentencia en vista previa", previewStatementRunButton: "Botón de ejecución de sentencia en vista previa",
wordWrap: "Ajuste de línea", wordWrap: "Ajuste de línea",
wordWrapDescription: "Ajustar las líneas largas al ancho del editor", wordWrapDescription: "Ajustar las líneas largas al ancho del editor",
+3 -1
View File
@@ -161,6 +161,8 @@ export default withEnglishFallback({
keywordCaseUpper: "Usa parole chiave SQL maiuscole", keywordCaseUpper: "Usa parole chiave SQL maiuscole",
sqlSemanticDiagnosticsToggleOn: "Diagnostica semantica SQL: attiva (clic per disattivare)", sqlSemanticDiagnosticsToggleOn: "Diagnostica semantica SQL: attiva (clic per disattivare)",
sqlSemanticDiagnosticsToggleOff: "Diagnostica semantica SQL: disattiva (clic per attivare)", sqlSemanticDiagnosticsToggleOff: "Diagnostica semantica SQL: disattiva (clic per attivare)",
insertValueHintsToggleOn: "Suggerimenti colonne INSERT: attivi (clic per disattivare)",
insertValueHintsToggleOff: "Suggerimenti colonne INSERT: disattivi (clic per attivare)",
autoCommit: "Auto Commit", autoCommit: "Auto Commit",
manualTransaction: "Transazione Manuale", manualTransaction: "Transazione Manuale",
autoCommitAgent: "Auto Commit (dipende dal backend dell'Agente)", autoCommitAgent: "Auto Commit (dipende dal backend dell'Agente)",
@@ -6333,7 +6335,7 @@ export default withEnglishFallback({
showCurrentStatementFrame: "Mostra cornice istruzione corrente", showCurrentStatementFrame: "Mostra cornice istruzione corrente",
showCurrentStatementFrameDescription: "Se attivo, l'editor SQL disegna un contorno intorno all'istruzione eseguibile corrente; se disattivato, il contorno è nascosto.", showCurrentStatementFrameDescription: "Se attivo, l'editor SQL disegna un contorno intorno all'istruzione eseguibile corrente; se disattivato, il contorno è nascosto.",
showInsertValueHints: "Mostra suggerimenti colonne nei VALUES di INSERT", showInsertValueHints: "Mostra suggerimenti colonne nei VALUES di INSERT",
showInsertValueHintsDescription: "Se attivo, l'editor SQL mostra il nome della colonna accanto a ogni valore nelle clausole INSERT ... VALUES.", showInsertValueHintsDescription: "Se attivo, l'editor SQL mostra il nome della colonna accanto a ogni valore o proiezione SELECT nelle clausole INSERT ... VALUES e INSERT ... SELECT.",
previewStatementRunButton: "Pulsante di esecuzione istruzione in anteprima", previewStatementRunButton: "Pulsante di esecuzione istruzione in anteprima",
wordWrap: "A capo automatico", wordWrap: "A capo automatico",
wordWrapDescription: "Manda a capo le righe lunghe entro la larghezza dell'editor", wordWrapDescription: "Manda a capo le righe lunghe entro la larghezza dell'editor",
+3 -1
View File
@@ -162,6 +162,8 @@ export default withEnglishFallback({
keywordCaseUpper: "SQLキーワードを大文字にする", keywordCaseUpper: "SQLキーワードを大文字にする",
sqlSemanticDiagnosticsToggleOn: "SQLセマンティック診断:オン(クリックでオフ)", sqlSemanticDiagnosticsToggleOn: "SQLセマンティック診断:オン(クリックでオフ)",
sqlSemanticDiagnosticsToggleOff: "SQLセマンティック診断:オフ(クリックでオン)", sqlSemanticDiagnosticsToggleOff: "SQLセマンティック診断:オフ(クリックでオン)",
insertValueHintsToggleOn: "INSERT 列名ヒント:オン(クリックでオフ)",
insertValueHintsToggleOff: "INSERT 列名ヒント:オフ(クリックでオン)",
autoCommit: "自動コミット", autoCommit: "自動コミット",
manualTransaction: "手動トランザクション", manualTransaction: "手動トランザクション",
autoCommitAgent: "自動コミット(Agentバックエンドに依存)", autoCommitAgent: "自動コミット(Agentバックエンドに依存)",
@@ -6359,7 +6361,7 @@ export default withEnglishFallback({
showCurrentStatementFrame: "現在の文の枠線を表示", showCurrentStatementFrame: "現在の文の枠線を表示",
showCurrentStatementFrameDescription: "有効にすると、SQLエディタで現在実行可能な文を枠線で示します。無効にすると枠線を表示しません。", showCurrentStatementFrameDescription: "有効にすると、SQLエディタで現在実行可能な文を枠線で示します。無効にすると枠線を表示しません。",
showInsertValueHints: "INSERT 値の列名ヒントを表示", showInsertValueHints: "INSERT 値の列名ヒントを表示",
showInsertValueHintsDescription: "有効にすると、INSERT ... VALUES 句の各値の横に対応する列名ヒントを表示します。", showInsertValueHintsDescription: "有効にすると、INSERT ... VALUES または INSERT ... SELECT の各値/投影の横に対応する列名ヒントを表示します。",
previewStatementRunButton: "文の実行ボタンのプレビュー", previewStatementRunButton: "文の実行ボタンのプレビュー",
wordWrap: "折り返し", wordWrap: "折り返し",
wordWrapDescription: "長い行をエディタ幅内で折り返します", wordWrapDescription: "長い行をエディタ幅内で折り返します",
+3 -1
View File
@@ -161,6 +161,8 @@ export default withEnglishFallback({
keywordCaseUpper: "SQL 키워드 대문자 사용", keywordCaseUpper: "SQL 키워드 대문자 사용",
sqlSemanticDiagnosticsToggleOn: "SQL 의미 진단: 켜짐 (클릭하여 끄기)", sqlSemanticDiagnosticsToggleOn: "SQL 의미 진단: 켜짐 (클릭하여 끄기)",
sqlSemanticDiagnosticsToggleOff: "SQL 의미 진단: 꺼짐 (클릭하여 켜기)", sqlSemanticDiagnosticsToggleOff: "SQL 의미 진단: 꺼짐 (클릭하여 켜기)",
insertValueHintsToggleOn: "INSERT 열 이름 힌트: 켜짐 (클릭하여 끄기)",
insertValueHintsToggleOff: "INSERT 열 이름 힌트: 꺼짐 (클릭하여 켜기)",
autoCommit: "자동 커밋", autoCommit: "자동 커밋",
manualTransaction: "수동 트랜잭션", manualTransaction: "수동 트랜잭션",
autoCommitAgent: "자동 커밋 (Agent 백엔드에 따라)", autoCommitAgent: "자동 커밋 (Agent 백엔드에 따라)",
@@ -6075,7 +6077,7 @@ export default withEnglishFallback({
showCurrentStatementFrame: "현재 구문 프레임 표시", showCurrentStatementFrame: "현재 구문 프레임 표시",
showCurrentStatementFrameDescription: "활성화하면 SQL 편집기가 현재 실행 가능한 구문 주위에 윤곽선을 그립니다. 비활성화하면 윤곽선이 숨겨집니다.", showCurrentStatementFrameDescription: "활성화하면 SQL 편집기가 현재 실행 가능한 구문 주위에 윤곽선을 그립니다. 비활성화하면 윤곽선이 숨겨집니다.",
showInsertValueHints: "INSERT 값 컬럼 힌트 표시", showInsertValueHints: "INSERT 값 컬럼 힌트 표시",
showInsertValueHintsDescription: "활성화하면 SQL 편집기가 INSERT ... VALUES 절의 각 값 옆에 컬럼 이름 힌트를 표시합니다.", showInsertValueHintsDescription: "활성화하면 SQL 편집기가 INSERT ... VALUES 또는 INSERT ... SELECT 절의 각 값/프로젝션 옆에 컬럼 이름 힌트를 표시합니다.",
previewStatementRunButton: "구문 실행 버튼 미리보기", previewStatementRunButton: "구문 실행 버튼 미리보기",
wordWrap: "자동 줄바꿈", wordWrap: "자동 줄바꿈",
wordWrapDescription: "편집기 너비 내에서 긴 줄을 줄바꿈합니다", wordWrapDescription: "편집기 너비 내에서 긴 줄을 줄바꿈합니다",
+3 -1
View File
@@ -162,6 +162,8 @@ export default withEnglishFallback({
keywordCaseUpper: "Usar palavras-chave SQL em maiúsculas", keywordCaseUpper: "Usar palavras-chave SQL em maiúsculas",
sqlSemanticDiagnosticsToggleOn: "Diagnóstico semântico de SQL: ativado (clique para desativar)", sqlSemanticDiagnosticsToggleOn: "Diagnóstico semântico de SQL: ativado (clique para desativar)",
sqlSemanticDiagnosticsToggleOff: "Diagnóstico semântico de SQL: desativado (clique para ativar)", sqlSemanticDiagnosticsToggleOff: "Diagnóstico semântico de SQL: desativado (clique para ativar)",
insertValueHintsToggleOn: "Dicas de colunas INSERT: ativadas (clique para desativar)",
insertValueHintsToggleOff: "Dicas de colunas INSERT: desativadas (clique para ativar)",
autoCommit: "Auto Commit", autoCommit: "Auto Commit",
manualTransaction: "Transação Manual", manualTransaction: "Transação Manual",
autoCommitAgent: "Auto Commit (depende do backend do Agente)", autoCommitAgent: "Auto Commit (depende do backend do Agente)",
@@ -6335,7 +6337,7 @@ export default withEnglishFallback({
showCurrentStatementFrame: "Mostrar moldura da instrução atual", showCurrentStatementFrame: "Mostrar moldura da instrução atual",
showCurrentStatementFrameDescription: "Quando ativado, o editor SQL desenha um contorno ao redor da instrução executável atual; quando desativado, o contorno fica oculto.", showCurrentStatementFrameDescription: "Quando ativado, o editor SQL desenha um contorno ao redor da instrução executável atual; quando desativado, o contorno fica oculto.",
showInsertValueHints: "Mostrar dicas de colunas em VALUES de INSERT", showInsertValueHints: "Mostrar dicas de colunas em VALUES de INSERT",
showInsertValueHintsDescription: "Quando ativado, o editor SQL mostra o nome da coluna ao lado de cada valor em cláusulas INSERT ... VALUES.", showInsertValueHintsDescription: "Quando ativado, o editor SQL mostra o nome da coluna ao lado de cada valor ou projeção SELECT em cláusulas INSERT ... VALUES e INSERT ... SELECT.",
previewStatementRunButton: "Botão de execução de instrução na prévia", previewStatementRunButton: "Botão de execução de instrução na prévia",
wordWrap: "Quebra de linha", wordWrap: "Quebra de linha",
wordWrapDescription: "Quebrar linhas longas dentro da largura do editor", wordWrapDescription: "Quebrar linhas longas dentro da largura do editor",
+3 -1
View File
@@ -85,6 +85,8 @@ export default withEnglishFallback({
keywordCaseUpper: "使用大写 SQL 关键字", keywordCaseUpper: "使用大写 SQL 关键字",
sqlSemanticDiagnosticsToggleOn: "SQL 语义诊断:已开启(点击关闭)", sqlSemanticDiagnosticsToggleOn: "SQL 语义诊断:已开启(点击关闭)",
sqlSemanticDiagnosticsToggleOff: "SQL 语义诊断:已关闭(点击开启)", sqlSemanticDiagnosticsToggleOff: "SQL 语义诊断:已关闭(点击开启)",
insertValueHintsToggleOn: "INSERT 列名提示:已开启(点击关闭)",
insertValueHintsToggleOff: "INSERT 列名提示:已关闭(点击开启)",
autoCommit: "自动提交", autoCommit: "自动提交",
manualTransaction: "手动事务", manualTransaction: "手动事务",
autoCommitAgent: "自动提交(取决于 Agent 后端数据库)", autoCommitAgent: "自动提交(取决于 Agent 后端数据库)",
@@ -6672,7 +6674,7 @@ export default withEnglishFallback({
showCurrentStatementFrame: "显示当前语句外框线", showCurrentStatementFrame: "显示当前语句外框线",
showCurrentStatementFrameDescription: "开启后,在 SQL 编辑器中用外框线标出当前可执行语句;关闭后不显示外框线。", showCurrentStatementFrameDescription: "开启后,在 SQL 编辑器中用外框线标出当前可执行语句;关闭后不显示外框线。",
showInsertValueHints: "显示 INSERT 值列名提示", showInsertValueHints: "显示 INSERT 值列名提示",
showInsertValueHintsDescription: "开启后,在 INSERT ... VALUES 子句的每个值旁显示对应列名提示。", showInsertValueHintsDescription: "开启后,在 INSERT ... VALUES 或 INSERT ... SELECT 的每个值/投影旁显示对应列名提示。",
previewStatementRunButton: "预览语句执行按钮", previewStatementRunButton: "预览语句执行按钮",
wordWrap: "自动换行", wordWrap: "自动换行",
wordWrapDescription: "长内容在编辑器宽度内自动折行显示", wordWrapDescription: "长内容在编辑器宽度内自动折行显示",
+3 -1
View File
@@ -162,6 +162,8 @@ export default withEnglishFallback({
keywordCaseUpper: "使用大寫 SQL 關鍵字", keywordCaseUpper: "使用大寫 SQL 關鍵字",
sqlSemanticDiagnosticsToggleOn: "SQL 語意診斷:已開啟(點擊關閉)", sqlSemanticDiagnosticsToggleOn: "SQL 語意診斷:已開啟(點擊關閉)",
sqlSemanticDiagnosticsToggleOff: "SQL 語意診斷:已關閉(點擊開啟)", sqlSemanticDiagnosticsToggleOff: "SQL 語意診斷:已關閉(點擊開啟)",
insertValueHintsToggleOn: "INSERT 欄位提示:已開啟(點擊關閉)",
insertValueHintsToggleOff: "INSERT 欄位提示:已關閉(點擊開啟)",
autoCommit: "自動提交", autoCommit: "自動提交",
manualTransaction: "手動事務", manualTransaction: "手動事務",
autoCommitAgent: "自動提交(取決於 Agent 後端資料庫)", autoCommitAgent: "自動提交(取決於 Agent 後端資料庫)",
@@ -5649,7 +5651,7 @@ export default withEnglishFallback({
showCurrentStatementFrame: "顯示目前語句外框線", showCurrentStatementFrame: "顯示目前語句外框線",
showCurrentStatementFrameDescription: "啟用後,在 SQL 編輯器中用外框線標出目前可執行語句;關閉後不顯示外框線。", showCurrentStatementFrameDescription: "啟用後,在 SQL 編輯器中用外框線標出目前可執行語句;關閉後不顯示外框線。",
showInsertValueHints: "顯示 INSERT 值欄位提示", showInsertValueHints: "顯示 INSERT 值欄位提示",
showInsertValueHintsDescription: "啟用後,在 INSERT ... VALUES 子句的每個值旁顯示對應欄位名稱提示。", showInsertValueHintsDescription: "啟用後,在 INSERT ... VALUES 或 INSERT ... SELECT 的每個值/投影旁顯示對應欄位名稱提示。",
previewStatementRunButton: "預覽語句執行按鈕", previewStatementRunButton: "預覽語句執行按鈕",
wordWrap: "自動換行", wordWrap: "自動換行",
wordWrapDescription: "長內容在編輯器寬度內自動折行顯示", wordWrapDescription: "長內容在編輯器寬度內自動折行顯示",
@@ -0,0 +1,20 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildInsertValueHintDecorations, insertValueHintsRefreshDispatchSpec, isInsertValueHintDecorationAt } from "@/lib/editor/codemirrorInsertValueHints";
test("insertValueHintsRefreshDispatchSpec disables scrollIntoView", () => {
assert.equal(insertValueHintsRefreshDispatchSpec.scrollIntoView, false);
});
test("isInsertValueHintDecorationAt detects widget anchors", () => {
const sql = "INSERT INTO t (a, b) VALUES (1, 2)";
const valueOne = sql.indexOf("1");
const valueTwo = sql.indexOf("2");
const decorations = buildInsertValueHintDecorations([
{ from: valueOne, column: "a" },
{ from: valueTwo, column: "b" },
]);
assert.equal(isInsertValueHintDecorationAt(decorations, valueOne), true);
assert.equal(isInsertValueHintDecorationAt(decorations, valueTwo), true);
assert.equal(isInsertValueHintDecorationAt(decorations, sql.indexOf("INSERT")), false);
});
@@ -1,9 +1,15 @@
import { StateEffect, type Extension } from "@codemirror/state"; import { EditorSelection, StateEffect, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view"; import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { buildInsertValueHints, expandToSqlStatementWindow, insertValueHintsNeedTableColumns, parseInsertValuesClauses, type InsertValueHint, type InsertValuesClause, type TextRange } from "@/lib/sql/insertValueHints"; import { matchDollarQuoteTag } from "@/lib/sql/semantic/tokens";
import { buildInsertValueHints, expandToSqlStatementWindow, findEnclosingDollarQuoteStartInText, insertValueHintsNeedTableColumns, parseInsertValuesClauses, type InsertValueHint, type InsertValuesClause, type TextRange } from "@/lib/sql/insertValueHints";
export const refreshInsertValueHintsEffect = StateEffect.define<null>(); export const refreshInsertValueHintsEffect = StateEffect.define<null>();
/** Shared dispatch options so hint refreshes never scroll the viewport. */
export const insertValueHintsRefreshDispatchSpec = {
scrollIntoView: false as const,
};
/** Debounce hint reparse while the user is typing / holding a key. */ /** Debounce hint reparse while the user is typing / holding a key. */
const INSERT_HINT_REPARSE_DELAY_MS = 80; const INSERT_HINT_REPARSE_DELAY_MS = 80;
@@ -57,6 +63,30 @@ function decorationsForHints(hints: readonly InsertValueHint[]): DecorationSet {
return Decoration.set(ranges); return Decoration.set(ranges);
} }
/** @internal Exported for unit tests. */
/**
* Whether a database type runs SQL-family statements, where INSERT column
* hints (and their SELECT extension) apply. NoSQL/document stores are excluded;
* keep this list in sync instead of re-inlining it at call sites.
*/
export function supportsInsertValueHints(databaseType: string | undefined | null): boolean {
return databaseType !== "redis" && databaseType !== "mongodb" && databaseType !== "elasticsearch" && databaseType !== "easysearch" && databaseType !== "meilisearch" && databaseType !== "victoriametrics";
}
export function buildInsertValueHintDecorations(hints: readonly InsertValueHint[]): DecorationSet {
return decorationsForHints(hints);
}
/** True when `pos` sits on an insert-value-hint widget anchor (value expression start). */
export function isInsertValueHintDecorationAt(decorations: DecorationSet, pos: number): boolean {
let found = false;
decorations.between(Math.max(0, pos - 1), pos + 1, (_from, _to, decoration) => {
const widget = decoration.spec?.widget;
if (widget instanceof InsertValueHintWidget) found = true;
});
return found;
}
function interestRanges(view: EditorView): TextRange[] { function interestRanges(view: EditorView): TextRange[] {
const ranges: TextRange[] = view.visibleRanges.map((range) => ({ from: range.from, to: range.to })); const ranges: TextRange[] = view.visibleRanges.map((range) => ({ from: range.from, to: range.to }));
const cursor = view.state.selection.main.head; const cursor = view.state.selection.main.head;
@@ -82,8 +112,20 @@ function parseClausesNearView(view: EditorView, dialectId: string): InsertValues
for (const range of interestRanges(view)) { for (const range of interestRanges(view)) {
// Pull a bounded neighborhood from the doc instead of materializing the whole script. // Pull a bounded neighborhood from the doc instead of materializing the whole script.
const pad = 32 * 1024; const pad = 32 * 1024;
const sliceFrom = Math.max(0, range.from - pad); let sliceFrom = Math.max(0, range.from - pad);
// When the cursor sits inside a PostgreSQL dollar-quoted routine body, a slice that still
// includes the opening $tag$ makes expandToSqlStatementWindow treat the whole body as one
// opaque literal. Advance sliceFrom past the opening delimiter instead.
const sliceTo = Math.min(doc.length, range.to + pad); const sliceTo = Math.min(doc.length, range.to + pad);
let dollarQuoteStart: number | null = null;
if (sliceTo > sliceFrom && doc.sliceString(sliceFrom, sliceTo).includes("$")) {
const probePos = Math.min(doc.length, Math.max(range.from, range.to));
dollarQuoteStart = findEnclosingDollarQuoteStartInText(doc, probePos) ?? findEnclosingDollarQuoteStartInText(doc, range.from);
}
if (dollarQuoteStart !== null) {
const openingTag = matchDollarQuoteTag(doc.sliceString(dollarQuoteStart, Math.min(doc.length, dollarQuoteStart + 64)), 0);
if (openingTag) sliceFrom = Math.max(sliceFrom, dollarQuoteStart + openingTag.length);
}
if (sliceTo <= sliceFrom) continue; if (sliceTo <= sliceFrom) continue;
const slice = doc.sliceString(sliceFrom, sliceTo); const slice = doc.sliceString(sliceFrom, sliceTo);
const window = expandToSqlStatementWindow(slice, range.from - sliceFrom, range.to - sliceFrom, dialectId); const window = expandToSqlStatementWindow(slice, range.from - sliceFrom, range.to - sliceFrom, dialectId);
@@ -185,7 +227,10 @@ export function createInsertValueHintsExtension(options: InsertValueHintsExtensi
this.reparseTimer = setTimeout(() => { this.reparseTimer = setTimeout(() => {
this.reparseTimer = null; this.reparseTimer = null;
// Trigger a lightweight transaction so update() runs compute once typing pauses. // Trigger a lightweight transaction so update() runs compute once typing pauses.
view.dispatch({ effects: refreshInsertValueHintsEffect.of(null) }); view.dispatch({
effects: refreshInsertValueHintsEffect.of(null),
...insertValueHintsRefreshDispatchSpec,
});
}, INSERT_HINT_REPARSE_DELAY_MS); }, INSERT_HINT_REPARSE_DELAY_MS);
} }
@@ -204,11 +249,31 @@ export function createInsertValueHintsExtension(options: InsertValueHintsExtensi
{ decorations: (value) => value.decorations }, { decorations: (value) => value.decorations },
); );
return [insertValueHintsTheme, plugin]; const clickWithoutScroll = EditorView.domEventHandlers({
mousedown(event, view) {
if (!(options.isEnabled?.() ?? true)) return false;
const pluginState = view.plugin(plugin);
if (!pluginState) return false;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
if (pos == null || !isInsertValueHintDecorationAt(pluginState.decorations, pos)) return false;
event.preventDefault();
view.dispatch({
selection: EditorSelection.cursor(pos),
...insertValueHintsRefreshDispatchSpec,
});
view.focus();
return true;
},
});
return [insertValueHintsTheme, plugin, clickWithoutScroll];
} }
export function requestInsertValueHintsRefresh(view: EditorView) { export function requestInsertValueHintsRefresh(view: EditorView) {
view.dispatch({ effects: refreshInsertValueHintsEffect.of(null) }); view.dispatch({
effects: refreshInsertValueHintsEffect.of(null),
...insertValueHintsRefreshDispatchSpec,
});
} }
export { insertValueHintsNeedTableColumns }; export { insertValueHintsNeedTableColumns };
+72 -2
View File
@@ -173,6 +173,14 @@ function findWordIndexAtDepth(tokens: readonly SqlSemanticToken[], word: string,
return -1; return -1;
} }
/** INSERT ... (SELECT ...) wraps the source query in an extra paren pair at sourceDepth. */
function findInsertSourceSelectIndex(tokens: readonly SqlSemanticToken[], from: number, sourceDepth: number): number {
if (tokens[from]?.text === "(" && tokens[from + 1]?.depth === sourceDepth + 1 && tokens[from + 1]?.kind === "word" && tokens[from + 1]?.normalized === "select") {
return from + 1;
}
return findWordIndexAtDepth(tokens, "select", from, sourceDepth);
}
function findClosingParenIndex(tokens: readonly SqlSemanticToken[], openIndex: number): number { function findClosingParenIndex(tokens: readonly SqlSemanticToken[], openIndex: number): number {
const open = tokens[openIndex]; const open = tokens[openIndex];
if (!open || open.text !== "(") return -1; if (!open || open.text !== "(") return -1;
@@ -203,8 +211,16 @@ function selectProjectionStarts(tokens: readonly SqlSemanticToken[], selectIndex
const depth = select.depth; const depth = select.depth;
let index = selectIndex + 1; let index = selectIndex + 1;
if (tokens[index]?.depth === depth && tokens[index]?.kind === "word" && (tokens[index]?.normalized === "all" || tokens[index]?.normalized === "distinct")) { if (tokens[index]?.depth === depth && tokens[index]?.kind === "word" && tokens[index]?.normalized === "all") {
index += 1; index += 1;
} else if (tokens[index]?.depth === depth && tokens[index]?.kind === "word" && tokens[index]?.normalized === "distinct") {
index += 1;
// PostgreSQL DISTINCT ON (cols) — skip the ON (...) clause before splitting projections.
if (tokens[index]?.depth === depth && tokens[index]?.kind === "word" && tokens[index]?.normalized === "on" && tokens[index + 1]?.text === "(") {
const closeIndex = findClosingParenIndex(tokens, index + 1);
if (closeIndex < 0) return [];
index = closeIndex + 1;
}
} }
const maybeTop = tokens[index]; const maybeTop = tokens[index];
@@ -278,7 +294,7 @@ function parseInsertClause(tokens: readonly SqlSemanticToken[], span: SqlSemanti
const sourceDepth = tokens[insertIndex]?.depth ?? 0; const sourceDepth = tokens[insertIndex]?.depth ?? 0;
const valuesIndex = findWordIndexAtDepth(tokens, "values", index, sourceDepth); const valuesIndex = findWordIndexAtDepth(tokens, "values", index, sourceDepth);
const selectIndex = findWordIndexAtDepth(tokens, "select", index, sourceDepth); const selectIndex = findInsertSourceSelectIndex(tokens, index, sourceDepth);
if (valuesIndex < 0 && selectIndex < 0) return null; if (valuesIndex < 0 && selectIndex < 0) return null;
const rows = selectIndex >= 0 && (valuesIndex < 0 || selectIndex < valuesIndex) ? [selectProjectionStarts(tokens, selectIndex)] : parseValuesRows(tokens, valuesIndex); const rows = selectIndex >= 0 && (valuesIndex < 0 || selectIndex < valuesIndex) ? [selectProjectionStarts(tokens, selectIndex)] : parseValuesRows(tokens, valuesIndex);
@@ -540,6 +556,60 @@ interface StatementWindowMemo {
// function -- a cache hit is guaranteed to be the same answer, not a staleness risk. // function -- a cache hit is guaranteed to be the same answer, not a staleness risk.
let statementWindowMemo: StatementWindowMemo | null = null; let statementWindowMemo: StatementWindowMemo | null = null;
export interface SqlTextSlice {
readonly length: number;
sliceString(from: number, to: number): string;
}
/**
* When `position` sits inside an unclosed PostgreSQL dollar-quoted body (`$tag$ ...`), return the
* opening tag offset; otherwise `null`. Scans from document start so callers can advance a bounded
* editor slice to start just past the opening delimiter (not include it) before statement-window
* heuristics run.
*/
export function findEnclosingDollarQuoteStart(sql: string, position: number): number | null {
return findEnclosingDollarQuoteStartInText({ length: sql.length, sliceString: (from, to) => sql.slice(from, to) }, position);
}
const DOLLAR_QUOTE_SLICE_SCAN_CHUNK = 64 * 1024;
/** When a chunk ends on `$`, extend the read so a `$tag$` marker split across chunks is still visible. */
const DOLLAR_QUOTE_CHUNK_TAIL_EXTENSION = 128;
export function findEnclosingDollarQuoteStartInText(doc: SqlTextSlice, position: number): number | null {
let openTag: string | null = null;
let openPos = -1;
const end = Math.max(0, Math.min(position, doc.length));
let scanned = 0;
while (scanned < end) {
let chunkEnd = Math.min(scanned + DOLLAR_QUOTE_SLICE_SCAN_CHUNK, end);
if (chunkEnd < end && chunkEnd > scanned && doc.sliceString(chunkEnd - 1, chunkEnd) === "$") {
chunkEnd = Math.min(chunkEnd + DOLLAR_QUOTE_CHUNK_TAIL_EXTENSION, end);
}
const chunk = doc.sliceString(scanned, chunkEnd);
for (let local = 0; local < chunk.length; ) {
const index = scanned + local;
if (index >= end) break;
if (chunk[local] === "$") {
const marker = matchDollarQuoteTag(chunk, local);
if (marker) {
if (openTag === null) {
openTag = marker;
openPos = index;
} else if (marker === openTag) {
openTag = null;
openPos = -1;
}
local += marker.length;
continue;
}
}
local += 1;
}
scanned = chunkEnd;
}
return openTag !== null ? openPos : null;
}
export function expandToSqlStatementWindow(sql: string, from: number, to: number, dialectId = "mysql"): TextRange { export function expandToSqlStatementWindow(sql: string, from: number, to: number, dialectId = "mysql"): TextRange {
if (statementWindowMemo && statementWindowMemo.sql === sql && statementWindowMemo.from === from && statementWindowMemo.to === to && statementWindowMemo.dialectId === dialectId) { if (statementWindowMemo && statementWindowMemo.sql === sql && statementWindowMemo.from === from && statementWindowMemo.to === to && statementWindowMemo.dialectId === dialectId) {
return statementWindowMemo.result; return statementWindowMemo.result;
+126 -1
View File
@@ -1,6 +1,6 @@
import { strict as assert } from "node:assert"; import { strict as assert } from "node:assert";
import { test } from "vitest"; import { test } from "vitest";
import { buildInsertValueHints, expandToSqlStatementWindow, parseInsertValueHints, parseInsertValueHintsInRanges, parseInsertValuesClauses } from "../../apps/desktop/src/lib/sql/insertValueHints.ts"; import { buildInsertValueHints, expandToSqlStatementWindow, findEnclosingDollarQuoteStart, parseInsertValueHints, parseInsertValueHintsInRanges, parseInsertValuesClauses } from "../../apps/desktop/src/lib/sql/insertValueHints.ts";
import { insertValueHintColumnNames } from "../../apps/desktop/src/lib/sql/insertValueHintColumns.ts"; import { insertValueHintColumnNames } from "../../apps/desktop/src/lib/sql/insertValueHintColumns.ts";
/** /**
@@ -17,6 +17,83 @@ function assertSublinearScaling(measureAt: (scale: number) => number, options: {
assert.ok(bigMs < Math.max(maxMs, smallMs * maxRatio), `${label}: ${smallScale}x took ${smallMs.toFixed(1)}ms, ${bigScale}x took ${bigMs.toFixed(1)}ms -- expected roughly bounded, not scaling with document size`); assert.ok(bigMs < Math.max(maxMs, smallMs * maxRatio), `${label}: ${smallScale}x took ${smallMs.toFixed(1)}ms, ${bigScale}x took ${bigMs.toFixed(1)}ms -- expected roughly bounded, not scaling with document size`);
} }
test("findEnclosingDollarQuoteStart locates the opening tag inside a PostgreSQL procedure body", () => {
const prefix = "CREATE OR REPLACE PROCEDURE p()\nLANGUAGE plpgsql\nAS $$\nBEGIN\n";
const sql = `${prefix}INSERT INTO users (id, name) SELECT src_id, src_name FROM staging;\nEND;\n$$;`;
const dollarOpen = sql.indexOf("$$");
assert.equal(findEnclosingDollarQuoteStart(sql, prefix.length + 10), dollarOpen);
assert.equal(findEnclosingDollarQuoteStart(sql, sql.indexOf("SELECT src_id")), dollarOpen);
assert.equal(findEnclosingDollarQuoteStart(sql, 0), null);
assert.equal(findEnclosingDollarQuoteStart(sql, dollarOpen), null);
});
test("insert hints isolate INSERT ... SELECT inside a dollar-quoted procedure when the slice would include the opening tag", () => {
const prefix = "CREATE OR REPLACE PROCEDURE p()\nLANGUAGE plpgsql\nAS $$\nBEGIN\n";
const insert = "INSERT INTO users (id, name) SELECT src_id, src_name FROM staging;";
const sql = `${prefix}${insert}\nEND;\n$$;`;
const cursor = sql.indexOf("INSERT");
let sliceFrom = 0;
const dollarStart = findEnclosingDollarQuoteStart(sql, cursor);
assert.equal(dollarStart, sql.indexOf("$$"));
const openingTag = "$$";
sliceFrom = Math.max(sliceFrom, dollarStart! + openingTag.length);
const slice = sql.slice(sliceFrom);
const relativeCursor = cursor - sliceFrom;
const window = expandToSqlStatementWindow(slice, relativeCursor, relativeCursor, "postgres");
const hints = buildInsertValueHints(parseInsertValuesClauses(slice.slice(window.from, window.to), "postgres"));
assert.deepEqual(
hints.map((hint) => hint.column),
["id", "name"],
);
});
test("slice adjustment probes viewport end when start is still before the opening dollar tag", () => {
const header = "CREATE OR REPLACE PROCEDURE p()\nLANGUAGE plpgsql\nAS ";
const prefix = `${header}$$\nBEGIN\n`;
const insert = "INSERT INTO users (id, name) SELECT src_id, src_name FROM staging;";
const sql = `${prefix}${insert}\nEND;\n$$;`;
const viewportFrom = 0;
const viewportTo = sql.indexOf("SELECT src_id") + 4;
const probePos = Math.min(sql.length, Math.max(viewportFrom, viewportTo));
assert.equal(findEnclosingDollarQuoteStart(sql, viewportFrom), null);
const dollarStart = findEnclosingDollarQuoteStart(sql, probePos) ?? findEnclosingDollarQuoteStart(sql, viewportFrom);
assert.equal(dollarStart, sql.indexOf("$$"));
let sliceFrom = 0;
sliceFrom = Math.max(sliceFrom, dollarStart! + "$$".length);
const slice = sql.slice(sliceFrom);
const relativeInsert = sql.indexOf("INSERT") - sliceFrom;
const window = expandToSqlStatementWindow(slice, relativeInsert, relativeInsert, "postgres");
const hints = buildInsertValueHints(parseInsertValuesClauses(slice.slice(window.from, window.to), "postgres"));
assert.deepEqual(
hints.map((hint) => hint.column),
["id", "name"],
);
});
test("findEnclosingDollarQuoteStart supports custom procedure tags", () => {
const prefix = "CREATE PROCEDURE p() AS $procedure$\nBEGIN\n";
const sql = `${prefix}INSERT INTO t (a) SELECT x FROM s;\nEND;\n$procedure$;`;
const tagOpen = sql.indexOf("$procedure$");
assert.equal(findEnclosingDollarQuoteStart(sql, sql.indexOf("INSERT")), tagOpen);
assert.equal(findEnclosingDollarQuoteStart(sql, sql.indexOf("SELECT x")), tagOpen);
});
test("findEnclosingDollarQuoteStart detects bodies when the opening tag spans a scan chunk boundary", () => {
const pad = "x".repeat(64 * 1024 - 1);
const opening = "$$";
const body = "BEGIN\nINSERT INTO t (a) SELECT x FROM s;\nEND;\n";
const sql = `${pad}${opening}${body}`;
const cursor = sql.indexOf("INSERT") + 5;
assert.equal(findEnclosingDollarQuoteStart(sql, cursor), pad.length);
});
test("findEnclosingDollarQuoteStart does not reopen a tag after chunk-extension overlap", () => {
const pad = "x".repeat(64 * 1024 - 1);
const sql = `${pad}$$ short $$`;
assert.equal(findEnclosingDollarQuoteStart(sql, pad.length + 4), pad.length);
assert.equal(findEnclosingDollarQuoteStart(sql, sql.length), null);
});
test("maps explicit column list to single-row VALUES", () => { test("maps explicit column list to single-row VALUES", () => {
const sql = "INSERT INTO auth_user (id, password, last_login) VALUES (5, 'hash', NULL)"; const sql = "INSERT INTO auth_user (id, password, last_login) VALUES (5, 'hash', NULL)";
const hints = parseInsertValueHints(sql); const hints = parseInsertValueHints(sql);
@@ -167,6 +244,54 @@ test("caps INSERT ... SELECT hints to the smaller target or projection count", (
); );
}); });
test("maps INSERT ... SELECT DISTINCT ON projections after skipping the ON clause", () => {
const sql = "INSERT INTO t (a, b) SELECT DISTINCT ON (x) y, z FROM s";
const hints = parseInsertValueHints(sql);
assert.deepEqual(
hints.map((hint) => ({ column: hint.column, text: sql.slice(hint.from).split(/[ ,]/u, 1)[0] })),
[
{ column: "a", text: "y" },
{ column: "b", text: "z" },
],
);
});
test("maps INSERT ... SELECT inside parenthesized source query", () => {
const sql = "INSERT INTO t (a, b) (SELECT x, y FROM z)";
const hints = parseInsertValueHints(sql);
assert.deepEqual(
hints.map((hint) => ({ column: hint.column, text: sql.slice(hint.from).split(/[ ,]/u, 1)[0] })),
[
{ column: "a", text: "x" },
{ column: "b", text: "y" },
],
);
});
test("maps INSERT ... SELECT with a CTE source query", () => {
const sql = "INSERT INTO t (a, b) WITH tmp AS (SELECT 1) SELECT x, y FROM tmp";
const hints = parseInsertValueHints(sql);
assert.deepEqual(
hints.map((hint) => ({ column: hint.column, text: sql.slice(hint.from).split(/[ ,]/u, 1)[0] })),
[
{ column: "a", text: "x" },
{ column: "b", text: "y" },
],
);
});
test("maps INSERT ... SELECT UNION only to the first SELECT branch", () => {
const sql = "INSERT INTO t (a, b) SELECT x, y FROM s1 UNION SELECT u, v FROM s2";
const hints = parseInsertValueHints(sql);
assert.deepEqual(
hints.map((hint) => ({ column: hint.column, text: sql.slice(hint.from).split(/[ ,]/u, 1)[0] })),
[
{ column: "a", text: "x" },
{ column: "b", text: "y" },
],
);
});
test("caps hints when value count exceeds column count", () => { test("caps hints when value count exceeds column count", () => {
const sql = "INSERT INTO t (a, b) VALUES (1, 2, 3)"; const sql = "INSERT INTO t (a, b) VALUES (1, 2, 3)";
const hints = parseInsertValueHints(sql); const hints = parseInsertValueHints(sql);